@hearthlog/runtime 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- package/README.md +0 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { get, writable } from 'svelte/store';
|
|
2
|
+
import { createSubscriber } from 'svelte/reactivity';
|
|
3
|
+
|
|
4
|
+
interface ApplicationViewState {
|
|
5
|
+
effectiveNow: string;
|
|
6
|
+
revision: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type InternalApplicationViewState = ApplicationViewState & { initialized: boolean };
|
|
10
|
+
|
|
11
|
+
const initialState: InternalApplicationViewState = {
|
|
12
|
+
effectiveNow: new Date().toISOString(),
|
|
13
|
+
revision: 0,
|
|
14
|
+
initialized: false,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// SAFETY: keyed on a private globalThis field that this module alone writes; a
|
|
18
|
+
// stale/mismatched value can only come from an incompatible older build.
|
|
19
|
+
const globalForApplicationView = globalThis as unknown as {
|
|
20
|
+
storApplicationViewStore?: ReturnType<typeof writable<InternalApplicationViewState>>;
|
|
21
|
+
storApplicationViewWaiters?: Array<(date: Date) => void>;
|
|
22
|
+
};
|
|
23
|
+
const state = globalForApplicationView.storApplicationViewStore ?? writable(initialState);
|
|
24
|
+
globalForApplicationView.storApplicationViewStore = state;
|
|
25
|
+
const readyWaiters = globalForApplicationView.storApplicationViewWaiters ?? [];
|
|
26
|
+
globalForApplicationView.storApplicationViewWaiters = readyWaiters;
|
|
27
|
+
|
|
28
|
+
// Lets `$derived(appClock.now())` and effects follow the clock as it advances.
|
|
29
|
+
const trackClock = createSubscriber((update) => {
|
|
30
|
+
let initial = true;
|
|
31
|
+
return state.subscribe(() => {
|
|
32
|
+
if (initial) {
|
|
33
|
+
initial = false;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
update();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const appClock = {
|
|
41
|
+
now(): Date {
|
|
42
|
+
trackClock();
|
|
43
|
+
return new Date(get(state).effectiveNow);
|
|
44
|
+
},
|
|
45
|
+
iso(): string {
|
|
46
|
+
trackClock();
|
|
47
|
+
return get(state).effectiveNow;
|
|
48
|
+
},
|
|
49
|
+
ready(): Promise<Date> {
|
|
50
|
+
const current = get(state);
|
|
51
|
+
if (current.initialized) return Promise.resolve(new Date(current.effectiveNow));
|
|
52
|
+
return new Promise((resolve) => readyWaiters.push(resolve));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Existing callers use this for internal links; routes no longer carry a read version. */
|
|
57
|
+
export function contextualHref(path: string, _currentUrl?: URL): string { return path; }
|
|
58
|
+
|
|
59
|
+
export function updateApplicationView(input: { effectiveNow: string }): void {
|
|
60
|
+
state.update((current) => ({
|
|
61
|
+
effectiveNow: input.effectiveNow,
|
|
62
|
+
revision: current.revision + 1,
|
|
63
|
+
initialized: true,
|
|
64
|
+
}));
|
|
65
|
+
const now = new Date(input.effectiveNow);
|
|
66
|
+
for (const resolve of readyWaiters.splice(0)) resolve(now);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function beginApplicationViewTransition(): void {
|
|
70
|
+
state.update((current) => ({ ...current, initialized: false }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function cancelApplicationViewTransition(): void {
|
|
74
|
+
const current = get(state);
|
|
75
|
+
state.set({ ...current, initialized: true });
|
|
76
|
+
const now = new Date(current.effectiveNow);
|
|
77
|
+
for (const resolve of readyWaiters.splice(0)) resolve(now);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resetApplicationView(): void {
|
|
81
|
+
state.set({ ...initialState, effectiveNow: new Date().toISOString() });
|
|
82
|
+
readyWaiters.splice(0);
|
|
83
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { recordTiming, measurementMetadata } from '../utils/perf.js';
|
|
2
|
+
import { bootstrapLocalStore, type LocalStoreBootstrapResult } from '../db/bootstrap.js';
|
|
3
|
+
import { eventLogFilename } from '../store/event-log.svelte.js';
|
|
4
|
+
import { activateCurrentReadTarget, beginReadTargetTransition, failReadTargetTransition } from './read-coordinator.js';
|
|
5
|
+
import { workspaceIsReady } from '@hearthlog/ui/state/readiness.js';
|
|
6
|
+
import { schedulePwaRegistration } from '../pwa/registration.js';
|
|
7
|
+
|
|
8
|
+
export interface WorkspaceTicket {
|
|
9
|
+
logId: string | null;
|
|
10
|
+
ready: Promise<LocalStoreBootstrapResult>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let ticket: WorkspaceTicket | undefined;
|
|
14
|
+
let recordedLocalReady = false;
|
|
15
|
+
let activationQueue: Promise<unknown> = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
/** Called by committed loads only: the app preloads code, never load functions. */
|
|
18
|
+
export function prepareWorkspace(url: URL): WorkspaceTicket {
|
|
19
|
+
const logId = eventLogFilename.value;
|
|
20
|
+
if (ticket && (!ticket.logId || ticket.logId === logId)) return ticket;
|
|
21
|
+
|
|
22
|
+
beginReadTargetTransition();
|
|
23
|
+
const next: WorkspaceTicket = { logId, ready: undefined! };
|
|
24
|
+
ticket = next;
|
|
25
|
+
const bootstrap = bootstrapLocalStore();
|
|
26
|
+
if (import.meta.env.PROD) void schedulePwaRegistration(bootstrap);
|
|
27
|
+
next.ready = activationQueue.catch(() => undefined).then(async () => {
|
|
28
|
+
const result = await bootstrap;
|
|
29
|
+
if (ticket !== next) throw new DOMException('Navigation superseded', 'AbortError');
|
|
30
|
+
next.logId = eventLogFilename.value;
|
|
31
|
+
if (!next.logId) throw new Error('Select an event log before opening this workspace.');
|
|
32
|
+
await activateCurrentReadTarget();
|
|
33
|
+
if (ticket !== next) {
|
|
34
|
+
beginReadTargetTransition();
|
|
35
|
+
throw new DOMException('Navigation superseded', 'AbortError');
|
|
36
|
+
}
|
|
37
|
+
if (!recordedLocalReady && typeof window !== 'undefined') {
|
|
38
|
+
recordedLocalReady = true;
|
|
39
|
+
recordTiming('startup', 'dataReady', performance.now(), {
|
|
40
|
+
...measurementMetadata(), baselineMetric: 'dataReady', route: url.pathname,
|
|
41
|
+
schemaRebuilt: result.schemaRebuilt, hasSelectedLog: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return { ...result, activeLogId: next.logId };
|
|
45
|
+
}).catch((error) => {
|
|
46
|
+
if (ticket === next) {
|
|
47
|
+
ticket = undefined;
|
|
48
|
+
failReadTargetTransition(error);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
// A fast rejection must be handled even before Svelte mounts its consumer.
|
|
53
|
+
void bootstrap.catch(() => undefined);
|
|
54
|
+
void next.ready.catch(() => undefined);
|
|
55
|
+
activationQueue = next.ready;
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function waitForWorkspace(): Promise<void> {
|
|
60
|
+
if (ticket) await ticket.ready;
|
|
61
|
+
if (!workspaceIsReady()) throw new Error('Local data is not ready.');
|
|
62
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { MODULE_LOCAL_STORES } from '#generated/modules';
|
|
2
|
+
|
|
3
|
+
export interface IndexedDbStoreStats {
|
|
4
|
+
name: string;
|
|
5
|
+
entries: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface IndexedDbDatabaseStats {
|
|
9
|
+
name: string;
|
|
10
|
+
label: string;
|
|
11
|
+
stores: IndexedDbStoreStats[];
|
|
12
|
+
measuresFiles: boolean;
|
|
13
|
+
fileCount: number;
|
|
14
|
+
pdfCount: number;
|
|
15
|
+
isdocCount: number;
|
|
16
|
+
fileBytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IndexedDbStats {
|
|
20
|
+
databases: IndexedDbDatabaseStats[];
|
|
21
|
+
fileCount: number;
|
|
22
|
+
fileBytes: number;
|
|
23
|
+
originUsage: number | null;
|
|
24
|
+
originQuota: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const knownDatabases: Record<string, { label: string; fileStore?: string; kinds?: boolean }> = {
|
|
28
|
+
// The enabled modules name their own file databases (`localStores` in the manifest).
|
|
29
|
+
...Object.fromEntries(MODULE_LOCAL_STORES.map(({ database, ...store }) => [database, store])),
|
|
30
|
+
'kv-store': { label: 'App settings' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function openExistingDatabase(name: string): Promise<IDBDatabase> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const request = indexedDB.open(name);
|
|
36
|
+
let expired = false;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
expired = true;
|
|
39
|
+
reject(new Error(`Timed out opening ${name}`));
|
|
40
|
+
}, 5000);
|
|
41
|
+
request.onupgradeneeded = () => {
|
|
42
|
+
// A database disappeared after enumeration. Do not create it.
|
|
43
|
+
request.transaction?.abort();
|
|
44
|
+
};
|
|
45
|
+
request.onsuccess = () => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (expired) {
|
|
48
|
+
request.result.close();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
resolve(request.result);
|
|
52
|
+
};
|
|
53
|
+
request.onerror = () => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
expired = true;
|
|
56
|
+
reject(request.error ?? new Error(`Could not open ${name}`));
|
|
57
|
+
};
|
|
58
|
+
request.onblocked = () => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
expired = true;
|
|
61
|
+
reject(new Error(`${name} is blocked by another tab`));
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readStore(db: IDBDatabase, name: string, measureFiles: boolean, kinds: boolean): Promise<{
|
|
67
|
+
entries: number; fileCount: number; pdfCount: number; isdocCount: number; fileBytes: number;
|
|
68
|
+
}> {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const transaction = db.transaction(name, 'readonly');
|
|
71
|
+
const store = transaction.objectStore(name);
|
|
72
|
+
const count = store.count();
|
|
73
|
+
let fileCount = 0;
|
|
74
|
+
let pdfCount = 0;
|
|
75
|
+
let isdocCount = 0;
|
|
76
|
+
let fileBytes = 0;
|
|
77
|
+
if (measureFiles) {
|
|
78
|
+
const cursor = store.openCursor();
|
|
79
|
+
cursor.onsuccess = () => {
|
|
80
|
+
const row = cursor.result;
|
|
81
|
+
if (!row) return;
|
|
82
|
+
const value = row.value as { blob?: unknown; kind?: string };
|
|
83
|
+
if (value.blob instanceof Blob) {
|
|
84
|
+
fileCount += 1;
|
|
85
|
+
fileBytes += value.blob.size;
|
|
86
|
+
if (kinds) {
|
|
87
|
+
if (value.kind === 'isdoc') isdocCount += 1;
|
|
88
|
+
else pdfCount += 1; // Older originals have no kind field.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
row.continue();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
transaction.oncomplete = () => resolve({
|
|
95
|
+
entries: count.result,
|
|
96
|
+
fileCount,
|
|
97
|
+
pdfCount,
|
|
98
|
+
isdocCount,
|
|
99
|
+
fileBytes,
|
|
100
|
+
});
|
|
101
|
+
transaction.onerror = () => reject(transaction.error ?? new Error(`Could not read ${name}`));
|
|
102
|
+
transaction.onabort = () => reject(transaction.error ?? new Error(`Could not read ${name}`));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read-only inventory. File bytes are Blob payloads, not an IndexedDB disk-size estimate. */
|
|
107
|
+
export async function loadIndexedDbStats(): Promise<IndexedDbStats> {
|
|
108
|
+
if (typeof indexedDB === 'undefined' || !indexedDB.databases) {
|
|
109
|
+
throw new Error('This browser cannot list IndexedDB databases.');
|
|
110
|
+
}
|
|
111
|
+
const discovered = await indexedDB.databases();
|
|
112
|
+
const names = discovered.flatMap((database) => database.name ? [database.name] : []);
|
|
113
|
+
const databases: IndexedDbDatabaseStats[] = [];
|
|
114
|
+
for (const name of names.sort()) {
|
|
115
|
+
const config = knownDatabases[name];
|
|
116
|
+
const db = await openExistingDatabase(name);
|
|
117
|
+
try {
|
|
118
|
+
const stores: IndexedDbStoreStats[] = [];
|
|
119
|
+
let fileCount = 0;
|
|
120
|
+
let pdfCount = 0;
|
|
121
|
+
let isdocCount = 0;
|
|
122
|
+
let fileBytes = 0;
|
|
123
|
+
for (const storeName of Array.from(db.objectStoreNames)) {
|
|
124
|
+
const result = await readStore(db, storeName, config?.fileStore === storeName, config?.kinds === true);
|
|
125
|
+
stores.push({ name: storeName, entries: result.entries });
|
|
126
|
+
fileCount += result.fileCount;
|
|
127
|
+
pdfCount += result.pdfCount;
|
|
128
|
+
isdocCount += result.isdocCount;
|
|
129
|
+
fileBytes += result.fileBytes;
|
|
130
|
+
}
|
|
131
|
+
databases.push({
|
|
132
|
+
name,
|
|
133
|
+
label: config?.label ?? name,
|
|
134
|
+
stores,
|
|
135
|
+
measuresFiles: !!config?.fileStore && stores.some((store) => store.name === config.fileStore),
|
|
136
|
+
fileCount,
|
|
137
|
+
pdfCount,
|
|
138
|
+
isdocCount,
|
|
139
|
+
fileBytes,
|
|
140
|
+
});
|
|
141
|
+
} finally {
|
|
142
|
+
db.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const estimate = await navigator.storage?.estimate?.().catch(() => null);
|
|
146
|
+
return {
|
|
147
|
+
databases,
|
|
148
|
+
fileCount: databases.reduce((sum, database) => sum + database.fileCount, 0),
|
|
149
|
+
fileBytes: databases.reduce((sum, database) => sum + database.fileBytes, 0),
|
|
150
|
+
originUsage: estimate?.usage ?? null,
|
|
151
|
+
originQuota: estimate?.quota ?? null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { format } from 'date-fns';
|
|
2
|
+
import type { AttentionBadge, AttentionContribution } from '@hearthlog/contracts';
|
|
3
|
+
import { appClock } from '../runtime/view.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Navigation badges: counts of work that is late, supplied by the enabled
|
|
7
|
+
* modules (`AttentionContribution.badges`). Counts come from SQL against the
|
|
8
|
+
* active log and refresh whenever events change; a failed read keeps the
|
|
9
|
+
* previous badges because they are advisory.
|
|
10
|
+
*/
|
|
11
|
+
class AttentionStore {
|
|
12
|
+
badges = $state<AttentionBadge[]>([]);
|
|
13
|
+
#sources: readonly AttentionContribution[] = [];
|
|
14
|
+
#run = 0;
|
|
15
|
+
|
|
16
|
+
/** Sets the composed modules' contributions (once, from the root layout). */
|
|
17
|
+
configure(sources: readonly AttentionContribution[]): void {
|
|
18
|
+
this.#sources = sources;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reminders for the native shell, from every module that has some. */
|
|
22
|
+
async reminders(today: Date) {
|
|
23
|
+
const lists = await Promise.all(this.#sources.map((source) => source.reminders?.({ today }) ?? []));
|
|
24
|
+
return lists.flat();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async refresh(): Promise<void> {
|
|
28
|
+
const run = ++this.#run;
|
|
29
|
+
const today = format(appClock.now(), 'yyyy-MM-dd');
|
|
30
|
+
try {
|
|
31
|
+
const next = (await Promise.all(this.#sources.map((source) => source.badges?.({ today }) ?? []))).flat();
|
|
32
|
+
if (run === this.#run) this.badges = next;
|
|
33
|
+
} catch {
|
|
34
|
+
// Keep the last known badges; the page itself still shows the details.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Badge for a navigation destination or sub-page id, or null when nothing is waiting. */
|
|
39
|
+
badge(id: string): AttentionBadge | null {
|
|
40
|
+
return this.badges.find((badge) => badge.count > 0 && badge.ids.includes(id)) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Total of all badges: the native app icon's badge. */
|
|
44
|
+
get total(): number {
|
|
45
|
+
return this.badges.reduce((sum, badge) => sum + badge.count, 0);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const attention = new AttentionStore();
|
|
50
|
+
|
|
51
|
+
export type AttentionTone = AttentionBadge['tone'];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merging a local batch with edits another device already had accepted.
|
|
3
|
+
*
|
|
4
|
+
* An update event carries only the fields it changed, so two devices that edit
|
|
5
|
+
* different fields of one record merge by simply sending the local batch again
|
|
6
|
+
* on top of the other device's events. A real clash is decided by a person:
|
|
7
|
+
* both devices set one field to different values, one device deleted a record
|
|
8
|
+
* the other edited, or the other way round. Nothing here rewrites a stored
|
|
9
|
+
* event; a resolution is either the unchanged batch sent again, or a new batch
|
|
10
|
+
* without the values the person gave up.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface EntityEdit {
|
|
14
|
+
type: string;
|
|
15
|
+
payload: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type EntityClash =
|
|
19
|
+
/** Both devices set one field to different values. */
|
|
20
|
+
| { kind: 'field'; entityId: string; field: string; mine: unknown; theirs: unknown }
|
|
21
|
+
/** Another device deleted the record this batch edits; sending would restore it. */
|
|
22
|
+
| { kind: 'edited_after_removed'; entityId: string }
|
|
23
|
+
/** This batch deletes a record another device edited since. */
|
|
24
|
+
| { kind: 'removed_after_edited'; entityId: string; fields: string[] };
|
|
25
|
+
|
|
26
|
+
type EditKind = 'added' | 'updated' | 'removed';
|
|
27
|
+
|
|
28
|
+
/** Entity events end in _ADDED, _UPDATED or _REMOVED; anything else is not an edit. */
|
|
29
|
+
function editKind(type: string): EditKind | null {
|
|
30
|
+
if (type.endsWith('_ADDED')) return 'added';
|
|
31
|
+
if (type.endsWith('_UPDATED')) return 'updated';
|
|
32
|
+
if (type.endsWith('_REMOVED')) return 'removed';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The value each field ends with: the last event that names it wins. */
|
|
37
|
+
function finalFields(edits: readonly EntityEdit[]): Map<string, unknown> {
|
|
38
|
+
const fields = new Map<string, unknown>();
|
|
39
|
+
for (const edit of edits) {
|
|
40
|
+
const kind = editKind(edit.type);
|
|
41
|
+
if (kind !== 'added' && kind !== 'updated') continue;
|
|
42
|
+
for (const [key, value] of Object.entries(edit.payload)) {
|
|
43
|
+
if (key !== 'id') fields.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return fields;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lastKind(edits: readonly EntityEdit[]): EditKind | null {
|
|
50
|
+
for (let index = edits.length - 1; index >= 0; index--) {
|
|
51
|
+
const kind = editKind(edits[index]!.type);
|
|
52
|
+
if (kind) return kind;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
58
|
+
return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function canonical(value: unknown): unknown {
|
|
62
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
63
|
+
if (value && typeof value === 'object') {
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(value as Record<string, unknown>)
|
|
66
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
67
|
+
.map(([key, nested]) => [key, canonical(nested)]),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What needs a decision between this batch's edits to one record (`mine`, in
|
|
75
|
+
* order) and the edits other devices had accepted since this batch's base
|
|
76
|
+
* version (`theirs`, in order). No clash means the batch can be sent again as is.
|
|
77
|
+
*/
|
|
78
|
+
export function findClashes(
|
|
79
|
+
entityId: string,
|
|
80
|
+
mine: readonly EntityEdit[],
|
|
81
|
+
theirs: readonly EntityEdit[],
|
|
82
|
+
): EntityClash[] {
|
|
83
|
+
const mineFinal = lastKind(mine);
|
|
84
|
+
const theirsFinal = lastKind(theirs);
|
|
85
|
+
if (mineFinal === null || theirsFinal === null) return [];
|
|
86
|
+
|
|
87
|
+
if (theirsFinal === 'removed') {
|
|
88
|
+
return mineFinal === 'removed' ? [] : [{ kind: 'edited_after_removed', entityId }];
|
|
89
|
+
}
|
|
90
|
+
if (mineFinal === 'removed') {
|
|
91
|
+
const touched = [...finalFields(theirs).keys()];
|
|
92
|
+
return touched.length > 0 ? [{ kind: 'removed_after_edited', entityId, fields: touched }] : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mineFields = finalFields(mine);
|
|
96
|
+
const clashes: EntityClash[] = [];
|
|
97
|
+
for (const [field, theirValue] of finalFields(theirs)) {
|
|
98
|
+
if (!mineFields.has(field)) continue;
|
|
99
|
+
const myValue = mineFields.get(field);
|
|
100
|
+
if (!sameValue(myValue, theirValue)) {
|
|
101
|
+
clashes.push({ kind: 'field', entityId, field, mine: myValue, theirs: theirValue });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return clashes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface BatchRow {
|
|
108
|
+
type: string;
|
|
109
|
+
entity_id: string | null;
|
|
110
|
+
payload: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The batch without what the person gave up: a clashing field is dropped from
|
|
115
|
+
* every event that sets it (an event left with nothing but its id is dropped),
|
|
116
|
+
* a record the other device deleted is left deleted, and a deletion of a record
|
|
117
|
+
* the other device edited is not sent.
|
|
118
|
+
*/
|
|
119
|
+
export function withoutClashes<T extends BatchRow>(rows: readonly T[], clashes: readonly EntityClash[]): T[] {
|
|
120
|
+
const kept: T[] = [];
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const own = clashes.filter((clash) => clash.entityId === row.entity_id);
|
|
123
|
+
const kind = editKind(row.type);
|
|
124
|
+
if (own.length === 0 || kind === null) {
|
|
125
|
+
kept.push(row);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (own.some((clash) => clash.kind === 'edited_after_removed')) continue;
|
|
129
|
+
if (kind === 'removed' && own.some((clash) => clash.kind === 'removed_after_edited')) continue;
|
|
130
|
+
const dropped = new Set(own.flatMap((clash) => (clash.kind === 'field' ? [clash.field] : [])));
|
|
131
|
+
if (dropped.size === 0 || kind === 'removed') {
|
|
132
|
+
kept.push(row);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
|
136
|
+
for (const field of dropped) delete payload[field];
|
|
137
|
+
if (Object.keys(payload).every((key) => key === 'id')) continue;
|
|
138
|
+
kept.push({ ...row, payload: JSON.stringify(payload) });
|
|
139
|
+
}
|
|
140
|
+
return kept;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function humanizeField(name: string): string {
|
|
144
|
+
const spaced = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ').trim().toLowerCase();
|
|
145
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A value as a person would read it in a one-line summary. */
|
|
149
|
+
export function formatClashValue(value: unknown): string {
|
|
150
|
+
if (value === null || value === undefined || value === '') return 'empty';
|
|
151
|
+
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
|
152
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
153
|
+
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function describeClash(clash: EntityClash): string {
|
|
157
|
+
switch (clash.kind) {
|
|
158
|
+
case 'field':
|
|
159
|
+
return `${humanizeField(clash.field)}: yours ${formatClashValue(clash.mine)}, theirs ${formatClashValue(clash.theirs)}`;
|
|
160
|
+
case 'edited_after_removed':
|
|
161
|
+
return `${humanizeField(clash.entityId.split(':')[0] ?? 'record')} was deleted on another device; your edit would restore it`;
|
|
162
|
+
case 'removed_after_edited':
|
|
163
|
+
return `${humanizeField(clash.entityId.split(':')[0] ?? 'record')} was edited on another device; your deletion would discard that edit`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function describeClashes(clashes: readonly EntityClash[]): string {
|
|
168
|
+
return clashes.map(describeClash).join(' · ');
|
|
169
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { db, requireActiveContext } from '../db/index.js';
|
|
2
|
+
|
|
3
|
+
export type EntitySyncState = 'pending' | 'blocked';
|
|
4
|
+
|
|
5
|
+
export const entitySyncState = $state<Record<string, EntitySyncState>>({});
|
|
6
|
+
|
|
7
|
+
export function getEntitySyncState(entityId: string): EntitySyncState | null {
|
|
8
|
+
return entitySyncState[entityId] ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function refreshEntitySyncState(): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const context = await requireActiveContext();
|
|
14
|
+
const rows = await db.selectFrom('raw_events as event')
|
|
15
|
+
.leftJoin('outbox_operations as operation', 'operation.batch_id', 'event.local_batch_id')
|
|
16
|
+
.select(['event.entity_id', 'event.sync_state', 'operation.status'])
|
|
17
|
+
.where('event.tenant_id', '=', context.tenantId)
|
|
18
|
+
.where('event.log_id', '=', context.logId)
|
|
19
|
+
.where('event.projection_state', '=', 'effective')
|
|
20
|
+
.where((eb) => eb.or([
|
|
21
|
+
eb('event.sync_state', '=', 'pending'),
|
|
22
|
+
eb('operation.status', '=', 'needs_attention'),
|
|
23
|
+
]))
|
|
24
|
+
.execute();
|
|
25
|
+
const next: Record<string, EntitySyncState> = {};
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
if (!row.entity_id || row.entity_id.startsWith('tombstone:')) continue;
|
|
28
|
+
const state = row.status === 'needs_attention' ? 'blocked' : 'pending';
|
|
29
|
+
if (state === 'blocked' || !next[row.entity_id]) next[row.entity_id] = state;
|
|
30
|
+
}
|
|
31
|
+
Object.keys(entitySyncState).forEach((key) => delete entitySyncState[key]);
|
|
32
|
+
Object.assign(entitySyncState, next);
|
|
33
|
+
} catch {
|
|
34
|
+
Object.keys(entitySyncState).forEach((key) => delete entitySyncState[key]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { get, set as setKv } from '../db/kv.js';
|
|
2
|
+
import { getLedgerClient } from '../ledger/index.js';
|
|
3
|
+
import { getLedgerCacheKey } from './local-context.js';
|
|
4
|
+
|
|
5
|
+
interface CachedLogSelection {
|
|
6
|
+
logId: string;
|
|
7
|
+
logName: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LEGACY_SELECTION_MIGRATED_KEY = 'ledger-selected-log-migrated';
|
|
11
|
+
|
|
12
|
+
class EventLogStore {
|
|
13
|
+
#value = $state<string | null>(null);
|
|
14
|
+
#logName = $state<string | null>(null);
|
|
15
|
+
#isInitialized = $state(false);
|
|
16
|
+
#initializationPromise: Promise<void>;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
this.#initializationPromise = this.initialize();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async initialize() {
|
|
23
|
+
try {
|
|
24
|
+
const selectionKey = await getLedgerCacheKey('selected-log');
|
|
25
|
+
const cachedSelection = await get<CachedLogSelection>(selectionKey);
|
|
26
|
+
let logId = cachedSelection?.logId ?? null;
|
|
27
|
+
let logName = cachedSelection?.logName ?? null;
|
|
28
|
+
|
|
29
|
+
if (!cachedSelection && !(await get<boolean>(LEGACY_SELECTION_MIGRATED_KEY))) {
|
|
30
|
+
logId = await get<string>('event-log-id');
|
|
31
|
+
logName = await get<string>('event-log-name');
|
|
32
|
+
if (logId) {
|
|
33
|
+
await setKv(selectionKey, { logId, logName });
|
|
34
|
+
}
|
|
35
|
+
await setKv(LEGACY_SELECTION_MIGRATED_KEY, true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!logId) {
|
|
39
|
+
try {
|
|
40
|
+
const client = getLedgerClient();
|
|
41
|
+
const logs = await client.listLogs();
|
|
42
|
+
|
|
43
|
+
if (logs.length > 0) {
|
|
44
|
+
// Prefer a log named 'default' if it exists
|
|
45
|
+
const defaultLog = logs.find(log => log.name === 'default');
|
|
46
|
+
const selectedLog = defaultLog || logs[0];
|
|
47
|
+
|
|
48
|
+
logId = selectedLog.id;
|
|
49
|
+
logName = selectedLog.name;
|
|
50
|
+
await this.set(logId, logName);
|
|
51
|
+
console.log(`Default event log set to: ${logName} (${logId})`);
|
|
52
|
+
} else {
|
|
53
|
+
// Auto-create a default log on first run
|
|
54
|
+
const defaultName = 'default';
|
|
55
|
+
try {
|
|
56
|
+
const response = await client.createLog({ name: defaultName });
|
|
57
|
+
logId = response.id;
|
|
58
|
+
logName = response.name || null;
|
|
59
|
+
await this.set(logId, logName ?? undefined);
|
|
60
|
+
console.log(`Created default event log in Ledger Service: ${logName} (${logId})`);
|
|
61
|
+
} catch (createErr) {
|
|
62
|
+
console.warn("Failed to auto-create default event log in Ledger Service:", createErr);
|
|
63
|
+
console.warn("No event logs found in Ledger Service. Please create one on the developer page.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("Failed to initialize default event log:", error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.#value = logId;
|
|
72
|
+
this.#logName = logName;
|
|
73
|
+
} finally {
|
|
74
|
+
this.#isInitialized = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async set(logId: string, logName?: string) {
|
|
79
|
+
this.#value = logId;
|
|
80
|
+
this.#logName = logName || null;
|
|
81
|
+
await setKv(await getLedgerCacheKey('selected-log'), {
|
|
82
|
+
logId,
|
|
83
|
+
logName: logName || null,
|
|
84
|
+
} satisfies CachedLogSelection);
|
|
85
|
+
// Retain legacy keys for older builds sharing this browser profile.
|
|
86
|
+
await setKv('event-log-id', logId);
|
|
87
|
+
if (logName) {
|
|
88
|
+
await setKv('event-log-name', logName);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get value() {
|
|
93
|
+
return this.#value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get name() {
|
|
97
|
+
return this.#logName;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get isInitialized() {
|
|
101
|
+
return this.#isInitialized;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ready(): Promise<void> {
|
|
105
|
+
return this.#initializationPromise;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const eventLogFilename = new EventLogStore();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Svelte-native signal for notifying when events data change
|
|
2
|
+
// Export a $state object and mutate its property (reassignment of exported $state is not allowed)
|
|
3
|
+
export let events = $state({ updatedAt: 0, refreshRequiredAt: 0 });
|
|
4
|
+
|
|
5
|
+
export function notifyEventsChanged(options: { routesInvalidated?: boolean } = {}) {
|
|
6
|
+
// Several changes can finish in the same millisecond.
|
|
7
|
+
events.updatedAt = Math.max(Date.now(), events.updatedAt + 1);
|
|
8
|
+
// Badges and file consumers still hear about changes whose route data was
|
|
9
|
+
// refreshed by sync. Only unhandled changes ask a page to refresh itself.
|
|
10
|
+
if (!options.routesInvalidated) events.refreshRequiredAt = events.updatedAt;
|
|
11
|
+
if (typeof window !== 'undefined') {
|
|
12
|
+
window.dispatchEvent(new CustomEvent('stor:events-changed'));
|
|
13
|
+
}
|
|
14
|
+
}
|