fix(core): modern Svelte 5 cleanup — Dexie .get() bug, typed API calls, inline confirms

- person_view.svelte: fix liveQuery using .get() (primary key, never set by V3)
  → .where('person_id').equals().first()
- people/[person_id]: same Dexie .get() fix for lq__person_obj
- person_view.svelte: replace 4x generic api.update_ae_obj → core_func.update_ae_obj__person
  (removes unused api import)
- Replace all browser confirm()/alert() dialogs (9 occurrences, 6 files) with
  inline two-click confirm state pattern (confirm_action = $state<string|null>)
  Affected: users, accounts, contacts, addresses, people, sites
- Bootstrap doc: add Dexie .get() trap to Section 5 and Mistake #8
This commit is contained in:
Scott Idem
2026-04-30 16:00:20 -04:00
parent 7be60c2b8b
commit 90adb19f5d
8 changed files with 270 additions and 98 deletions

View File

@@ -179,7 +179,7 @@ async function load_ae_obj_id__my_obj({ api_cfg, obj_id }) {
### ID convention — never use `_id_random` fields
The V3 API uses random string IDs (e.g. `event_file_id = "aBc123"`). The `*_id_random`
fields are legacy aliases. Always use the short form:
fields are legacy aliases. The integer version of the ID is never returned by the API. Always use the short form:
```ts
// ✅ Correct
event_file_obj.event_file_id
@@ -187,6 +187,7 @@ event_file_obj.event_file_id
// ❌ Wrong — legacy alias, don't use
event_file_obj.event_file_id_random
```
The short ".id" is also the randomized string, **not an integer** (autonum).
### PATCH — only field values in the body
```ts
@@ -205,6 +206,23 @@ x-aether-api-key: <PUBLIC_AE_API_SECRET_KEY>
x-account-id: <account_id>
```
### Dexie queries — always use the object ID index, not `.get()`
All `db_core` (and other module) Dexie tables define their schema with `id` as the first
field (primary key), followed by the object's string ID (e.g. `person_id`). V3 **never**
returns `id`, so every record stored in Dexie has `id = undefined`. Calling `.get(value)`
does a primary key lookup — it will always miss when passed a string object ID.
```ts
// ❌ Wrong — .get() uses the primary key (id), which V3 never populates:
liveQuery(() => db_core.person.get(person_id))
// ✅ Correct — use .where() on the indexed object ID field:
liveQuery(() => db_core.person.where('person_id').equals(person_id).first())
```
This applies to every table in every module (`db_core`, `db_events`, etc.).
When looking up a single object by its string ID, always use `.where().equals().first()`.
---
## 6. Naming Conventions
@@ -253,7 +271,14 @@ These are real incidents — know them before you start.
6. **Deleting files with `rm`** — always move to `~/tmp/agents_trash`. A deleted file may
contain context that's not recoverable from git if it was gitignored.
7. **Treating `$effect` blocks as auth bypass risks** — a `$effect` inside a child
8. **Dexie `.get()` with a string object ID returns `undefined`** — Dexie `.get(value)`
looks up by the table's **primary key**, which is `id` (the first schema field). The V3
API never returns `id`, so it is always `undefined` in stored records. Passing a string
object ID (e.g. `person_id`) to `.get()` will silently return nothing. Always use
`.where('person_id').equals(person_id).first()` instead. This has caused liveQuery
blocks to always produce `undefined` even when the record exists in Dexie.
9. **Treating `$effect` blocks as auth bypass risks** — a `$effect` inside a child
component cannot bypass a parent `+layout.svelte` auth gate. Children only mount if
the parent calls `{@render children?.()}`. Adding redundant auth guards to `$effect`
blocks that can only run after the parent gate already passed is unnecessary — and

View File

@@ -22,6 +22,8 @@ let account_id = $derived($page.params.account_id ?? '');
let account: any = $state(null);
let loading = $state(true);
let saving = $state(false);
let save_success = $state(false);
let confirm_action = $state<string | null>(null);
async function load_account() {
loading = true;
@@ -59,13 +61,18 @@ async function handle_save() {
});
if (result) {
alert('Account updated successfully');
save_success = true;
setTimeout(() => (save_success = false), 2500);
}
saving = false;
}
async function handle_delete() {
if (!confirm('Are you sure you want to disable this account?')) return;
if (confirm_action !== 'disable_account') {
confirm_action = 'disable_account';
return;
}
confirm_action = null;
const result = await delete_ae_obj_id__account({
api_cfg: $ae_api,
@@ -98,13 +105,33 @@ async function handle_delete() {
</h1>
</div>
</div>
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={loading || saving}>
<Trash2 size={16} class="mr-2" /> Disable
</button>
<div class="flex items-center gap-2">
{#if confirm_action === 'disable_account'}
<span class="text-sm font-bold text-red-600">Disable this account?</span>
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={loading || saving}>
<Trash2 size={16} class="mr-2" /> Confirm Disable
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
{:else}
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={loading || saving}>
<Trash2 size={16} class="mr-2" /> Disable
</button>
{/if}
{#if save_success}
<span class="flex items-center gap-1 text-sm font-bold text-green-600">
<Info size={16} /> Saved
</span>
{/if}
<button
class="btn btn-sm preset-filled-primary font-bold shadow-lg"
onclick={handle_save}

View File

@@ -31,6 +31,7 @@ let address_id = $derived($page.params.address_id ?? '');
let address: any = $state(null);
let loading = $state(true);
let is_editing = $state(false);
let confirm_action = $state<string | null>(null);
async function load_data() {
loading = true;
@@ -51,7 +52,11 @@ onMount(() => {
});
async function handle_delete() {
if (!confirm('Permanently delete this address?')) return;
if (confirm_action !== 'delete_address') {
confirm_action = 'delete_address';
return;
}
confirm_action = null;
await delete_ae_obj_id__address({
api_cfg: $ae_api,
address_id,
@@ -88,7 +93,7 @@ async function handle_delete() {
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button
class="btn btn-sm preset-tonal-secondary font-bold shadow-sm"
onclick={() => (is_editing = !is_editing)}
@@ -99,12 +104,27 @@ async function handle_delete() {
<Edit size={16} class="mr-2" /> Edit Mode
{/if}
</button>
<button
class="btn btn-sm preset-tonal-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
{#if confirm_action === 'delete_address'}
<span class="self-center text-sm font-bold text-red-600">Delete this address?</span>
<button
class="btn btn-sm preset-filled-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Confirm Delete
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
{:else}
<button
class="btn btn-sm preset-tonal-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
{/if}
</div>
</header>

View File

@@ -32,6 +32,7 @@ let contact_id = $derived($page.params.contact_id ?? '');
let contact: any = $state(null);
let loading = $state(true);
let is_editing = $state(false);
let confirm_action = $state<string | null>(null);
async function load_data() {
loading = true;
@@ -52,7 +53,11 @@ onMount(() => {
});
async function handle_delete() {
if (!confirm('Permanently delete this contact?')) return;
if (confirm_action !== 'delete_contact') {
confirm_action = 'delete_contact';
return;
}
confirm_action = null;
await delete_ae_obj_id__contact({
api_cfg: $ae_api,
contact_id,
@@ -87,7 +92,7 @@ async function handle_delete() {
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-2">
<button
class="btn btn-sm preset-tonal-secondary font-bold shadow-sm"
onclick={() => (is_editing = !is_editing)}
@@ -98,12 +103,27 @@ async function handle_delete() {
<Edit size={16} class="mr-2" /> Edit Mode
{/if}
</button>
<button
class="btn btn-sm preset-tonal-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
{#if confirm_action === 'delete_contact'}
<span class="self-center text-sm font-bold text-red-600">Delete this contact?</span>
<button
class="btn btn-sm preset-filled-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Confirm Delete
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
{:else}
<button
class="btn btn-sm preset-tonal-error font-bold shadow-sm"
onclick={handle_delete}
disabled={loading}>
<Trash2 size={16} class="mr-2" /> Delete
</button>
{/if}
</div>
</header>

View File

@@ -70,8 +70,10 @@ $effect(() => {
});
let is_editing = $state(false);
let confirm_action = $state<string | null>(null);
let lq__person_obj = liveQuery(() => db_core.person.get($slct.person_id));
// .get() uses the primary key (id), which V3 never populates — always use .where().equals().first()
let lq__person_obj = liveQuery(() => db_core.person.where('person_id').equals($slct.person_id ?? '').first());
$slct.lq__person_obj = lq__person_obj;
let available_users: any[] = $state([]);
@@ -131,7 +133,11 @@ async function load_unlinked_users() {
}
async function handle_link_user(user_id: string) {
if (!confirm('Link this person to this user account?')) return;
if (confirm_action !== `link_user_${user_id}`) {
confirm_action = `link_user_${user_id}`;
return;
}
confirm_action = null;
const result = await update_ae_obj__person({
api_cfg: $ae_api,
@@ -147,7 +153,11 @@ async function handle_link_user(user_id: string) {
}
async function handle_unlink_user() {
if (!confirm('Unlink this person from their user account?')) return;
if (confirm_action !== 'unlink_user') {
confirm_action = 'unlink_user';
return;
}
confirm_action = null;
const result = await update_ae_obj__person({
api_cfg: $ae_api,
@@ -268,11 +278,25 @@ $ae_loc.person.show_content__person_page_help = false;
<span>User Account Linking</span>
</div>
{#if $lq__person_obj?.user_id}
<button
class="btn btn-sm preset-tonal-error"
onclick={handle_unlink_user}>
<Unlink size={14} class="mr-2" /> Unlink User
</button>
{#if confirm_action === 'unlink_user'}
<span class="self-center text-sm font-bold text-red-600">Unlink this user?</span>
<button
class="btn btn-sm preset-filled-error"
onclick={handle_unlink_user}>
<Unlink size={14} class="mr-2" /> Confirm Unlink
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
{:else}
<button
class="btn btn-sm preset-tonal-error"
onclick={handle_unlink_user}>
<Unlink size={14} class="mr-2" /> Unlink User
</button>
{/if}
{:else}
<button
class="btn btn-sm preset-tonal-primary"
@@ -323,28 +347,46 @@ $ae_loc.person.show_content__person_page_help = false;
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each available_users as user (user.user_id)}
<button
class="card preset-tonal-primary hover:preset-filled-primary flex flex-col gap-1 p-3 text-left transition-all"
onclick={() =>
handle_link_user(user.user_id)}>
<span
class="flex items-center gap-2 font-bold">
<User size={14} />
{user.username}
</span>
<span class="truncate text-xs opacity-70"
>{user.email}</span>
<div class="mt-1 flex gap-1">
{#if user.super}<span
class="badge preset-filled-error text-[10px]"
>Super</span
>{/if}
{#if user.manager}<span
class="badge preset-filled-warning text-[10px]"
>Manager</span
>{/if}
{#if confirm_action === `link_user_${user.user_id}`}
<div class="card preset-tonal-warning flex flex-col gap-2 p-3">
<span class="text-sm font-bold">Link {user.username}?</span>
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-primary flex-1"
onclick={() => handle_link_user(user.user_id)}>
Confirm
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
</div>
</div>
</button>
{:else}
<button
class="card preset-tonal-primary hover:preset-filled-primary flex flex-col gap-1 p-3 text-left transition-all"
onclick={() =>
handle_link_user(user.user_id)}>
<span
class="flex items-center gap-2 font-bold">
<User size={14} />
{user.username}
</span>
<span class="truncate text-xs opacity-70"
>{user.email}</span>
<div class="mt-1 flex gap-1">
{#if user.super}<span
class="badge preset-filled-error text-[10px]"
>Super</span
>{/if}
{#if user.manager}<span
class="badge preset-filled-warning text-[10px]"
>Manager</span
>{/if}
</div>
</button>
{/if}
{/each}
</div>
{/if}

View File

@@ -14,8 +14,6 @@ import { liveQuery } from 'dexie';
import type { key_val } from '$lib/stores/ae_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import { api } from '$lib/api/api';
import { core_func } from '$lib/ae_core/ae_core_functions';
import {
ae_snip,
@@ -62,11 +60,9 @@ $effect(() => {
}
});
// .get() uses the primary key (id), which V3 never populates — always use .where().equals().first()
let lq__person_obj = $derived(
liveQuery(async () => {
let results = await db_core.person.get(person_id);
return results;
})
liveQuery(() => db_core.person.where('person_id').equals(person_id).first())
);
ae_tmp.value__data_json = null;
@@ -304,11 +300,10 @@ $effect(() => {
<button
type="button"
onclick={async () => {
await api.update_ae_obj({
await core_func.update_ae_obj__person({
api_cfg: $ae_api,
obj_type: 'person',
obj_id: $lq__person_obj?.person_id,
fields: { hide: !$lq__person_obj?.hide }
person_id: $lq__person_obj?.person_id,
data_kv: { hide: !$lq__person_obj?.hide }
});
core_func.load_ae_obj_id__person({
api_cfg: $ae_api,
@@ -337,11 +332,10 @@ $effect(() => {
<button
type="button"
onclick={async () => {
await api.update_ae_obj({
await core_func.update_ae_obj__person({
api_cfg: $ae_api,
obj_type: 'person',
obj_id: $lq__person_obj?.person_id,
fields: { enable: !$lq__person_obj?.enable }
person_id: $lq__person_obj?.person_id,
data_kv: { enable: !$lq__person_obj?.enable }
});
core_func.load_ae_obj_id__person({
api_cfg: $ae_api,
@@ -373,11 +367,10 @@ $effect(() => {
<button
type="button"
onclick={async () => {
await api.update_ae_obj({
await core_func.update_ae_obj__person({
api_cfg: $ae_api,
obj_type: 'person',
obj_id: $lq__person_obj?.person_id,
fields: { priority: !$lq__person_obj?.priority }
person_id: $lq__person_obj?.person_id,
data_kv: { priority: !$lq__person_obj?.priority }
});
core_func.load_ae_obj_id__person({
api_cfg: $ae_api,
@@ -404,13 +397,10 @@ $effect(() => {
<button
type="button"
onclick={async () => {
await api.update_ae_obj({
await core_func.update_ae_obj__person({
api_cfg: $ae_api,
obj_type: 'person',
obj_id: $lq__person_obj?.person_id,
fields: {
allow_auth_key: !$lq__person_obj?.allow_auth_key
}
person_id: $lq__person_obj?.person_id,
data_kv: { allow_auth_key: !$lq__person_obj?.allow_auth_key }
});
core_func.load_ae_obj_id__person({
api_cfg: $ae_api,

View File

@@ -34,6 +34,7 @@ let site: any = $state(null);
let domain_li: any[] = $state([]);
let loading = $state(true);
let saving = $state(false);
let confirm_action = $state<string | null>(null);
async function load_data() {
loading = true;
@@ -106,7 +107,11 @@ async function handle_toggle_domain(dom: any) {
}
async function handle_delete_domain(dom: any) {
if (!confirm(`Remove domain ${dom.fqdn}?`)) return;
if (confirm_action !== `delete_domain_${dom.site_domain_id}`) {
confirm_action = `delete_domain_${dom.site_domain_id}`;
return;
}
confirm_action = null;
await delete_ae_obj_id__site_domain({
api_cfg: $ae_api,
site_id,
@@ -297,7 +302,7 @@ async function handle_delete_domain(dom: any) {
: 'Disabled'}
</span>
</div>
<div class="flex gap-1">
<div class="flex items-center gap-1">
<button
class="btn btn-icon btn-sm {dom.enable
? 'preset-tonal-success'
@@ -307,13 +312,29 @@ async function handle_delete_domain(dom: any) {
title="Toggle Active">
<Save size={14} />
</button>
<button
class="btn btn-icon btn-sm preset-tonal-error"
onclick={() =>
handle_delete_domain(dom)}
title="Delete Domain">
<Trash2 size={14} />
</button>
{#if confirm_action === `delete_domain_${dom.site_domain_id}`}
<span class="text-xs font-bold text-red-600">Remove {dom.fqdn}?</span>
<button
class="btn btn-icon btn-sm preset-filled-error"
onclick={() => handle_delete_domain(dom)}
title="Confirm Delete">
<Trash2 size={14} />
</button>
<button
class="btn btn-icon btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}
title="Cancel">
</button>
{:else}
<button
class="btn btn-icon btn-sm preset-tonal-error"
onclick={() =>
handle_delete_domain(dom)}
title="Delete Domain">
<Trash2 size={14} />
</button>
{/if}
</div>
</div>
{/each}

View File

@@ -30,6 +30,8 @@ interface Props {
let { data }: Props = $props();
let user = $state(untrack(() => data.user));
let saving = $state(false);
let save_success = $state(false);
let confirm_action = $state<string | null>(null);
$effect(() => {
user = data.user;
@@ -58,13 +60,18 @@ async function handle_save() {
});
if (result) {
alert('User updated successfully');
save_success = true;
setTimeout(() => (save_success = false), 2500);
}
saving = false;
}
async function handle_delete() {
if (!confirm('Are you sure you want to disable this user account?')) return;
if (confirm_action !== 'disable_user') {
confirm_action = 'disable_user';
return;
}
confirm_action = null;
const result = await delete_ae_obj_id__user({
api_cfg: $ae_api,
@@ -104,13 +111,33 @@ async function handle_delete() {
</div>
</div>
</div>
<div class="flex gap-2">
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={saving}>
<Trash2 size={16} class="mr-2" /> Disable
</button>
<div class="flex items-center gap-2">
{#if confirm_action === 'disable_user'}
<span class="text-sm font-bold text-red-600">Disable this user?</span>
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={saving}>
<Trash2 size={16} class="mr-2" /> Confirm Disable
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (confirm_action = null)}>
Cancel
</button>
{:else}
<button
class="btn btn-sm preset-filled-error font-bold shadow-lg"
onclick={handle_delete}
disabled={saving}>
<Trash2 size={16} class="mr-2" /> Disable
</button>
{/if}
{#if save_success}
<span class="flex items-center gap-1 text-sm font-bold text-green-600">
<CircleCheck size={16} /> Saved
</span>
{/if}
<button
class="btn btn-sm preset-filled-primary font-bold shadow-lg"
onclick={handle_save}