@hearthlog/runtime 0.0.0-stage → 0.1.0
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,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkOfflineReadiness,
|
|
3
|
+
markOfflineReadinessFailed,
|
|
4
|
+
markOfflineReadinessInstalling,
|
|
5
|
+
} from '../store/offline-readiness.svelte.js';
|
|
6
|
+
import { notify, type NotificationAction } from '@hearthlog/ui/state/notifications.svelte.js';
|
|
7
|
+
|
|
8
|
+
const BOOTSTRAP_WAIT_MS = 3_000;
|
|
9
|
+
const IDLE_WAIT_MS = 1_000;
|
|
10
|
+
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1_000;
|
|
11
|
+
|
|
12
|
+
type RegisterPwa = () => void | Promise<void>;
|
|
13
|
+
|
|
14
|
+
export interface PwaRegistrationSchedulerDependencies {
|
|
15
|
+
register: RegisterPwa;
|
|
16
|
+
setTimeout: typeof globalThis.setTimeout;
|
|
17
|
+
clearTimeout: typeof globalThis.clearTimeout;
|
|
18
|
+
requestIdleCallback?: (
|
|
19
|
+
callback: () => void,
|
|
20
|
+
options: { timeout: number },
|
|
21
|
+
) => number;
|
|
22
|
+
requestAnimationFrame?: (callback: () => void) => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PwaUpdateNotifier = (
|
|
26
|
+
message: string,
|
|
27
|
+
action?: NotificationAction,
|
|
28
|
+
) => unknown;
|
|
29
|
+
|
|
30
|
+
export function createPwaUpdatePrompt(
|
|
31
|
+
notifyInfo: PwaUpdateNotifier,
|
|
32
|
+
reload: () => void,
|
|
33
|
+
): () => void {
|
|
34
|
+
let shown = false;
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
if (shown) return;
|
|
38
|
+
shown = true;
|
|
39
|
+
notifyInfo('A new version of the app is available.', {
|
|
40
|
+
label: 'Reload',
|
|
41
|
+
run: reload,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function waitForBootstrap(
|
|
47
|
+
bootstrapPromise: Promise<unknown>,
|
|
48
|
+
dependencies: PwaRegistrationSchedulerDependencies,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
let finished = false;
|
|
52
|
+
const finish = () => {
|
|
53
|
+
if (finished) return;
|
|
54
|
+
finished = true;
|
|
55
|
+
dependencies.clearTimeout(timeoutId);
|
|
56
|
+
resolve();
|
|
57
|
+
};
|
|
58
|
+
const timeoutId = dependencies.setTimeout(finish, BOOTSTRAP_WAIT_MS);
|
|
59
|
+
void bootstrapPromise.then(finish, finish);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function waitForIdle(dependencies: PwaRegistrationSchedulerDependencies): Promise<void> {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
if (dependencies.requestIdleCallback) {
|
|
66
|
+
dependencies.requestIdleCallback(resolve, { timeout: IDLE_WAIT_MS });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (dependencies.requestAnimationFrame) {
|
|
70
|
+
dependencies.requestAnimationFrame(resolve);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createPwaRegistrationScheduler(
|
|
78
|
+
dependencies: PwaRegistrationSchedulerDependencies,
|
|
79
|
+
): (bootstrapPromise: Promise<unknown>) => Promise<void> {
|
|
80
|
+
let scheduled: Promise<void> | null = null;
|
|
81
|
+
|
|
82
|
+
return (bootstrapPromise) => {
|
|
83
|
+
scheduled ??= waitForBootstrap(bootstrapPromise, dependencies)
|
|
84
|
+
.then(() => waitForIdle(dependencies))
|
|
85
|
+
.then(() => dependencies.register());
|
|
86
|
+
return scheduled;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function registerPwa(): Promise<void> {
|
|
91
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return;
|
|
92
|
+
|
|
93
|
+
markOfflineReadinessInstalling();
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const showUpdatePrompt = createPwaUpdatePrompt(
|
|
97
|
+
notify.info,
|
|
98
|
+
() => window.location.reload(),
|
|
99
|
+
);
|
|
100
|
+
const handleWorkerActivated = () => {
|
|
101
|
+
showUpdatePrompt();
|
|
102
|
+
void checkOfflineReadiness();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// A controller present before registration means any new worker is an
|
|
106
|
+
// update; otherwise the first install is the offline-ready moment.
|
|
107
|
+
const hadController = Boolean(navigator.serviceWorker.controller);
|
|
108
|
+
const registration = await navigator.serviceWorker.register('/service-worker.js', {
|
|
109
|
+
type: 'module',
|
|
110
|
+
scope: '/',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
registration.addEventListener('updatefound', () => {
|
|
114
|
+
const worker = registration.installing;
|
|
115
|
+
worker?.addEventListener('statechange', () => {
|
|
116
|
+
if (worker.state !== 'activated') return;
|
|
117
|
+
if (hadController) handleWorkerActivated();
|
|
118
|
+
else void checkOfflineReadiness();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
scheduleServiceWorkerUpdateChecks(registration);
|
|
122
|
+
void navigator.serviceWorker.ready.then(() => checkOfflineReadiness());
|
|
123
|
+
|
|
124
|
+
// The replacement worker claims this page immediately. The current
|
|
125
|
+
// document already has its chunks in memory, so let the user choose
|
|
126
|
+
// when to reload after the replacement worker has activated.
|
|
127
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
128
|
+
void checkOfflineReadiness();
|
|
129
|
+
}, { once: true });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
markOfflineReadinessFailed(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function scheduleServiceWorkerUpdateChecks(registration: ServiceWorkerRegistration): void {
|
|
136
|
+
const checkForUpdate = () => {
|
|
137
|
+
if (!navigator.onLine || document.visibilityState !== 'visible') return;
|
|
138
|
+
void registration.update().catch((error) => {
|
|
139
|
+
console.debug('[PWA] Service worker update check failed:', error);
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS);
|
|
144
|
+
document.addEventListener('visibilitychange', checkForUpdate);
|
|
145
|
+
window.addEventListener('online', checkForUpdate);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const requestIdleCallback = typeof window !== 'undefined'
|
|
149
|
+
? (window as Window & {
|
|
150
|
+
requestIdleCallback?: (
|
|
151
|
+
callback: () => void,
|
|
152
|
+
options: { timeout: number },
|
|
153
|
+
) => number;
|
|
154
|
+
}).requestIdleCallback?.bind(window)
|
|
155
|
+
: undefined;
|
|
156
|
+
|
|
157
|
+
const scheduleRegistration = createPwaRegistrationScheduler({
|
|
158
|
+
register: registerPwa,
|
|
159
|
+
setTimeout: globalThis.setTimeout.bind(globalThis),
|
|
160
|
+
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
|
161
|
+
requestIdleCallback,
|
|
162
|
+
requestAnimationFrame: typeof window !== 'undefined'
|
|
163
|
+
? window.requestAnimationFrame.bind(window)
|
|
164
|
+
: undefined,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export function schedulePwaRegistration(bootstrapPromise: Promise<unknown>): Promise<void> {
|
|
168
|
+
return scheduleRegistration(bootstrapPromise);
|
|
169
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { bootstrapLocalStore, type LocalStoreBootstrapResult } from '../db/bootstrap.js';
|
|
2
|
+
import { db, requireActiveContext, sqlocalDb } from '../db/index.js';
|
|
3
|
+
import { shutdownSynchronization } from '../store/sync.svelte.js';
|
|
4
|
+
import { executeLocalReset, type ResetDependencies } from './reset-sequence.js';
|
|
5
|
+
|
|
6
|
+
export type { ResetDependencies } from './reset-sequence.js';
|
|
7
|
+
|
|
8
|
+
export interface LocalRecoverySummary {
|
|
9
|
+
pending: number | null;
|
|
10
|
+
blocked: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getLocalRecoverySummary(): Promise<LocalRecoverySummary> {
|
|
14
|
+
try {
|
|
15
|
+
const context = await requireActiveContext();
|
|
16
|
+
const [pending, blocked] = await Promise.all([
|
|
17
|
+
db.selectFrom('raw_events').select((eb) => eb.fn.count('id').as('count'))
|
|
18
|
+
.where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
|
|
19
|
+
.where('sync_state', 'in', ['pending', 'conflicted']).executeTakeFirst(),
|
|
20
|
+
db.selectFrom('outbox_operations').select((eb) => eb.fn.count('id').as('count'))
|
|
21
|
+
.where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
|
|
22
|
+
.where('status', '=', 'needs_attention').executeTakeFirst(),
|
|
23
|
+
]);
|
|
24
|
+
return { pending: Number(pending?.count ?? 0), blocked: Number(blocked?.count ?? 0) };
|
|
25
|
+
} catch {
|
|
26
|
+
return { pending: null, blocked: null };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getLocalDatabaseFile(): Promise<Blob> {
|
|
31
|
+
return await sqlocalDb.getDatabaseFile() as unknown as Blob;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function downloadLocalDatabase(): Promise<void> {
|
|
35
|
+
const file = await getLocalDatabaseFile();
|
|
36
|
+
const url = URL.createObjectURL(file);
|
|
37
|
+
const link = document.createElement('a');
|
|
38
|
+
link.href = url;
|
|
39
|
+
link.download = `stor-${new Date().toISOString().slice(0, 10)}.sqlite3`;
|
|
40
|
+
document.body.appendChild(link);
|
|
41
|
+
link.click();
|
|
42
|
+
link.remove();
|
|
43
|
+
URL.revokeObjectURL(url);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function retryLocalMigration(): Promise<LocalStoreBootstrapResult> {
|
|
47
|
+
return bootstrapLocalStore();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resetLocalDataFromLedger(
|
|
51
|
+
dependencies: ResetDependencies = {
|
|
52
|
+
stopSynchronization: shutdownSynchronization,
|
|
53
|
+
deleteDatabase: () => sqlocalDb.deleteDatabaseFile(),
|
|
54
|
+
markReset: () => sessionStorage.setItem('databaseReset', 'true'),
|
|
55
|
+
hardReload: () => location.reload(),
|
|
56
|
+
},
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
await executeLocalReset(dependencies);
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ResetDependencies {
|
|
2
|
+
stopSynchronization: () => Promise<void>;
|
|
3
|
+
deleteDatabase: () => Promise<void>;
|
|
4
|
+
markReset: () => void;
|
|
5
|
+
hardReload: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function executeLocalReset(dependencies: ResetDependencies): Promise<void> {
|
|
9
|
+
await dependencies.stopSynchronization();
|
|
10
|
+
dependencies.markReset();
|
|
11
|
+
await dependencies.deleteDatabase();
|
|
12
|
+
dependencies.hardReload();
|
|
13
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { invalidateAll } from '$app/navigation';
|
|
2
|
+
import { isSameDay, startOfTomorrow } from 'date-fns';
|
|
3
|
+
import { advanceReadTargetClock } from './read-coordinator.js';
|
|
4
|
+
import { appClock } from './view.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keeps the workspace clock current while a tab stays open: it advances when
|
|
8
|
+
* the tab becomes visible and at local midnight, and pages reload their data
|
|
9
|
+
* when the date changes so "today" moves with it.
|
|
10
|
+
*/
|
|
11
|
+
export function startWorkspaceClock(): () => void {
|
|
12
|
+
let midnight: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
|
|
14
|
+
async function advance(): Promise<void> {
|
|
15
|
+
const previous = appClock.now();
|
|
16
|
+
const now = new Date();
|
|
17
|
+
if (!await advanceReadTargetClock(now)) return;
|
|
18
|
+
if (!isSameDay(previous, now)) await invalidateAll();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function scheduleMidnight(): void {
|
|
22
|
+
clearTimeout(midnight);
|
|
23
|
+
// A second past midnight, so the new date is certain.
|
|
24
|
+
midnight = setTimeout(() => {
|
|
25
|
+
void advance();
|
|
26
|
+
scheduleMidnight();
|
|
27
|
+
}, startOfTomorrow().getTime() - Date.now() + 1_000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function handleVisibility(): void {
|
|
31
|
+
if (document.visibilityState !== 'visible') return;
|
|
32
|
+
void advance();
|
|
33
|
+
// Timers in hidden tabs are throttled; re-arm from the real time.
|
|
34
|
+
scheduleMidnight();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
document.addEventListener('visibilitychange', handleVisibility);
|
|
38
|
+
scheduleMidnight();
|
|
39
|
+
return () => {
|
|
40
|
+
clearTimeout(midnight);
|
|
41
|
+
document.removeEventListener('visibilitychange', handleVisibility);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import type { PageResource } from './page-data.svelte.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The data a page last showed, kept on screen while the next load runs, so a
|
|
6
|
+
* save or a change of address swaps the page atomically instead of flashing
|
|
7
|
+
* skeletons. Skeletons show only before the first paint. Call it while the
|
|
8
|
+
* component initialises.
|
|
9
|
+
*/
|
|
10
|
+
export function keepPageData<T>(resource: () => PageResource<T>) {
|
|
11
|
+
let kept = $state.raw<T | null>(null);
|
|
12
|
+
/** Counts applied loads; endless lists read the blocks on screen again when it changes. */
|
|
13
|
+
let revision = $state(0);
|
|
14
|
+
|
|
15
|
+
$effect(() => {
|
|
16
|
+
const current = resource();
|
|
17
|
+
if (current.loading) return;
|
|
18
|
+
const next = current.data;
|
|
19
|
+
untrack(() => {
|
|
20
|
+
kept = next;
|
|
21
|
+
revision += 1;
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
get data(): T {
|
|
27
|
+
return kept ?? resource().data;
|
|
28
|
+
},
|
|
29
|
+
/** True only until the first load has been applied. */
|
|
30
|
+
get loading(): boolean {
|
|
31
|
+
return kept === null && resource().loading;
|
|
32
|
+
},
|
|
33
|
+
get revision(): number {
|
|
34
|
+
return revision;
|
|
35
|
+
},
|
|
36
|
+
/** What `providePageResource` hands to `CommandOnly`, `DataValue` and `PageDataStatus`. */
|
|
37
|
+
get display(): { loading: boolean; error: unknown; measurement: PageResource<T>['measurement'] } {
|
|
38
|
+
const current = resource();
|
|
39
|
+
return { loading: kept === null && current.loading, error: current.error, measurement: current.measurement };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { LoadEvent } from '@sveltejs/kit';
|
|
2
|
+
import { get } from 'svelte/store';
|
|
3
|
+
import { workspaceReadiness } from '@hearthlog/ui/state/readiness.js';
|
|
4
|
+
import { recordTiming, measurementMetadata, getNavigationTiming } from '../utils/perf.js';
|
|
5
|
+
import type { WorkspaceTicket } from './workspace.js';
|
|
6
|
+
|
|
7
|
+
type RouteEvent = Pick<LoadEvent, 'url' | 'params' | 'depends' | 'parent'>;
|
|
8
|
+
export type PagePublisher<T> = (update: Partial<T>, region?: string) => void;
|
|
9
|
+
type Measurement = { id: string; navigationId: string; kind: 'startup' | 'navigation' | 'refresh'; start: number; finish: () => void };
|
|
10
|
+
const pendingRenders = new Set<Promise<void>>();
|
|
11
|
+
let activeResource: PageResource<any> | undefined;
|
|
12
|
+
let hasRenderedPage = false;
|
|
13
|
+
|
|
14
|
+
export class PageResource<T> {
|
|
15
|
+
value = $state.raw<T>();
|
|
16
|
+
error = $state.raw<unknown>(null);
|
|
17
|
+
pending = $state(true);
|
|
18
|
+
revision = $state(0);
|
|
19
|
+
scope = $state('');
|
|
20
|
+
promise!: Promise<T>;
|
|
21
|
+
measurement = $state.raw<Measurement>(undefined!);
|
|
22
|
+
#request = 0;
|
|
23
|
+
#recordedMeasurement = '';
|
|
24
|
+
#readiness = $state(get(workspaceReadiness));
|
|
25
|
+
#unsubscribe?: () => void;
|
|
26
|
+
#partial = $state.raw<Partial<T>>({});
|
|
27
|
+
#partialScope = $state('');
|
|
28
|
+
#regions = $state.raw(new Set<string>());
|
|
29
|
+
|
|
30
|
+
constructor(public key: string, readonly route: string, readonly initial: T, readonly requiresWorkspace = true) {}
|
|
31
|
+
|
|
32
|
+
get usable(): boolean {
|
|
33
|
+
return this.value !== undefined && (!this.requiresWorkspace || (this.#readiness.ready && this.scope === this.#readiness.key));
|
|
34
|
+
}
|
|
35
|
+
get data(): T {
|
|
36
|
+
if (this.usable) return this.value!;
|
|
37
|
+
return this.#readiness.ready && this.#partialScope === this.#readiness.key
|
|
38
|
+
? { ...this.initial, ...this.#partial } : this.initial;
|
|
39
|
+
}
|
|
40
|
+
get loading(): boolean { return !this.usable; }
|
|
41
|
+
get refreshing(): boolean { return this.usable && this.pending; }
|
|
42
|
+
isRegionLoading(region: string): boolean {
|
|
43
|
+
return !this.usable && !(this.#readiness.ready && this.#partialScope === this.#readiness.key && this.#regions.has(region));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
attach(): () => void {
|
|
47
|
+
this.#unsubscribe?.();
|
|
48
|
+
this.#unsubscribe = workspaceReadiness.subscribe((state) => this.#readiness = state);
|
|
49
|
+
return () => {
|
|
50
|
+
this.#unsubscribe?.();
|
|
51
|
+
this.measurement.finish();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
load(callback: (publish: PagePublisher<T>) => Promise<T>): void {
|
|
56
|
+
const request = ++this.#request;
|
|
57
|
+
this.measurement?.finish();
|
|
58
|
+
let finish!: () => void;
|
|
59
|
+
const rendered = new Promise<void>((resolve) => finish = resolve);
|
|
60
|
+
pendingRenders.add(rendered);
|
|
61
|
+
void rendered.then(() => pendingRenders.delete(rendered));
|
|
62
|
+
const navigation = getNavigationTiming();
|
|
63
|
+
const kind = this.value !== undefined ? 'refresh' : navigation.start || hasRenderedPage ? 'navigation' : 'startup';
|
|
64
|
+
this.measurement = {
|
|
65
|
+
id: crypto.randomUUID(),
|
|
66
|
+
navigationId: navigation.id,
|
|
67
|
+
kind,
|
|
68
|
+
start: kind === 'startup' ? 0 : kind === 'navigation' ? navigation.start || performance.now() : performance.now(),
|
|
69
|
+
finish,
|
|
70
|
+
};
|
|
71
|
+
this.pending = true;
|
|
72
|
+
this.error = null;
|
|
73
|
+
this.#partial = {};
|
|
74
|
+
this.#regions = new Set();
|
|
75
|
+
this.promise = callback((update, region) => {
|
|
76
|
+
if (request !== this.#request || activeResource !== this) return;
|
|
77
|
+
this.#partialScope = get(workspaceReadiness).key;
|
|
78
|
+
this.#partial = { ...this.#partial, ...update };
|
|
79
|
+
if (region) this.#regions = new Set([...this.#regions, region]);
|
|
80
|
+
}).then((value) => {
|
|
81
|
+
if (request !== this.#request || activeResource !== this) return value;
|
|
82
|
+
this.scope = get(workspaceReadiness).key;
|
|
83
|
+
if (this.requiresWorkspace) this.key = `${this.scope}:${this.route}`;
|
|
84
|
+
this.value = value;
|
|
85
|
+
this.pending = false;
|
|
86
|
+
this.revision += 1;
|
|
87
|
+
return value;
|
|
88
|
+
}).catch((error) => {
|
|
89
|
+
if (request === this.#request) {
|
|
90
|
+
this.error = error;
|
|
91
|
+
this.pending = false;
|
|
92
|
+
finish();
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
});
|
|
96
|
+
void this.promise.catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
rendered(measurement: Measurement): void {
|
|
100
|
+
if (measurement !== this.measurement || this.pending || !this.usable || this.error || this.#recordedMeasurement === measurement.id) return;
|
|
101
|
+
this.#recordedMeasurement = measurement.id;
|
|
102
|
+
recordTiming(measurement.kind === 'startup' ? 'startup' : 'ui', 'pageContentReady', performance.now() - measurement.start, {
|
|
103
|
+
...measurementMetadata(), baselineMetric: 'pageContentReady', route: this.route,
|
|
104
|
+
navigationId: measurement.navigationId, loadId: measurement.id, kind: measurement.kind, outcome: 'success',
|
|
105
|
+
});
|
|
106
|
+
hasRenderedPage = true;
|
|
107
|
+
measurement.finish();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Load functions finish immediately; only their resource waits for local data. */
|
|
112
|
+
export function deferPage<T>(event: RouteEvent, initial: NoInfer<T>, loader: (publish: PagePublisher<NoInfer<T>>) => Promise<T>): { pageResource: PageResource<T> } {
|
|
113
|
+
// Snapshot tracked URL properties before deferring work. Action-only queries
|
|
114
|
+
// must not discard a loaded page or an open drawer.
|
|
115
|
+
const url = new URL(event.url.href);
|
|
116
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
117
|
+
if (key.startsWith('new')) url.searchParams.delete(key);
|
|
118
|
+
}
|
|
119
|
+
const route = `${url.pathname}${url.search}`;
|
|
120
|
+
const key = `${get(workspaceReadiness).key}:${route}`;
|
|
121
|
+
if (activeResource?.key !== key) {
|
|
122
|
+
activeResource?.measurement.finish();
|
|
123
|
+
activeResource = new PageResource<T>(key, route, initial);
|
|
124
|
+
}
|
|
125
|
+
const resource = activeResource as PageResource<T>;
|
|
126
|
+
// parent() itself is immediate; never await the parent's deferred payload
|
|
127
|
+
// before returning from a load function.
|
|
128
|
+
const parent = event.parent() as Promise<{ workspace: WorkspaceTicket }>;
|
|
129
|
+
resource.load(async (publish) => {
|
|
130
|
+
const data = await parent;
|
|
131
|
+
await data.workspace.ready;
|
|
132
|
+
const scope = get(workspaceReadiness).key;
|
|
133
|
+
const result = await loader((update, region) => {
|
|
134
|
+
if (scope === get(workspaceReadiness).key) publish(update, region);
|
|
135
|
+
});
|
|
136
|
+
if (scope !== get(workspaceReadiness).key) throw new DOMException('Workspace changed', 'AbortError');
|
|
137
|
+
return result;
|
|
138
|
+
});
|
|
139
|
+
return { pageResource: resource };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Wait for data AND its visible consumer, rather than merely invalidate(). */
|
|
143
|
+
export async function waitForPageUpdates(): Promise<void> {
|
|
144
|
+
while (pendingRenders.size) await Promise.all([...pendingRenders]);
|
|
145
|
+
if (activeResource?.error) throw activeResource.error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { providePageResource } from '@hearthlog/ui/state/page-context.js';
|
|
149
|
+
|
|
150
|
+
export function deferLocalPage<T>(url: URL, initial: NoInfer<T>, loader: () => Promise<T>): { pageResource: PageResource<T> } {
|
|
151
|
+
const route = `${url.pathname}${url.search}`;
|
|
152
|
+
const key = `local:${route}`;
|
|
153
|
+
if (activeResource?.key !== key) {
|
|
154
|
+
activeResource?.measurement.finish();
|
|
155
|
+
activeResource = new PageResource<T>(key, route, initial, false);
|
|
156
|
+
}
|
|
157
|
+
const resource = activeResource as PageResource<T>;
|
|
158
|
+
resource.load(loader);
|
|
159
|
+
return { pageResource: resource };
|
|
160
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the page puts in the phone top bar (below 768 px): one glance figure
|
|
5
|
+
* that opens the page's summary sheet, an optional context line such as the
|
|
6
|
+
* day in view, and whether the bar needs the log button's room. The shell
|
|
7
|
+
* owns the bar; `PhoneGlance` fills these while its page is mounted.
|
|
8
|
+
*/
|
|
9
|
+
class PhoneHeader {
|
|
10
|
+
glance = $state<Snippet | null>(null);
|
|
11
|
+
context = $state<string | null>(null);
|
|
12
|
+
hideLog = $state(false);
|
|
13
|
+
/** While rows are being selected the bar names the count and offers Select all and Cancel. */
|
|
14
|
+
selection = $state<{ label: string; onSelectAll?: () => void; onCancel: () => void } | null>(null);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const phoneHeader = new PhoneHeader();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { beginWorkspaceTransition, completeWorkspaceTransition, failWorkspaceTransition } from '@hearthlog/ui/state/readiness.js';
|
|
2
|
+
import { db, requireActiveContext } from '../db/index.js';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import {
|
|
5
|
+
beginApplicationViewTransition,
|
|
6
|
+
cancelApplicationViewTransition,
|
|
7
|
+
updateApplicationView,
|
|
8
|
+
} from './view.js';
|
|
9
|
+
import { createReadModelSession, type ReadModelDatabase, type ReadModelSession, type ReadScope, type ReadView } from '../db/read-session.js';
|
|
10
|
+
|
|
11
|
+
type ActiveReadTarget = { scope: ReadScope; view: ReadView };
|
|
12
|
+
type CoordinatorState = {
|
|
13
|
+
activeTarget: ActiveReadTarget | null;
|
|
14
|
+
activationQueue: Promise<void>;
|
|
15
|
+
waiters: Array<{ resolve: (target: ActiveReadTarget) => void; reject: (error: unknown) => void }>;
|
|
16
|
+
error: unknown;
|
|
17
|
+
transitioning: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const globalForCoordinator = globalThis as unknown as { storReadCoordinator?: CoordinatorState };
|
|
21
|
+
const coordinatorState = globalForCoordinator.storReadCoordinator ?? {
|
|
22
|
+
activeTarget: null,
|
|
23
|
+
activationQueue: Promise.resolve(),
|
|
24
|
+
waiters: [],
|
|
25
|
+
error: null,
|
|
26
|
+
transitioning: false,
|
|
27
|
+
};
|
|
28
|
+
globalForCoordinator.storReadCoordinator = coordinatorState;
|
|
29
|
+
|
|
30
|
+
export async function activateCurrentReadTarget(now = new Date()): Promise<void> {
|
|
31
|
+
const context = await requireActiveContext();
|
|
32
|
+
const scope = { tenantId: context.tenantId, logId: context.logId };
|
|
33
|
+
const view = { effectiveNow: now.toISOString() };
|
|
34
|
+
const activation = coordinatorState.activationQueue.then(() => {
|
|
35
|
+
coordinatorState.activeTarget = { scope, view };
|
|
36
|
+
coordinatorState.transitioning = false;
|
|
37
|
+
coordinatorState.error = null;
|
|
38
|
+
completeWorkspaceTransition(JSON.stringify([scope.tenantId, scope.logId]));
|
|
39
|
+
for (const waiter of coordinatorState.waiters.splice(0)) waiter.resolve(coordinatorState.activeTarget);
|
|
40
|
+
updateApplicationView({ effectiveNow: view.effectiveNow });
|
|
41
|
+
});
|
|
42
|
+
coordinatorState.activationQueue = activation.catch(() => undefined);
|
|
43
|
+
await activation;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Moves the active read target's clock forward. A log transition in progress
|
|
48
|
+
* sets its own clock when it activates, so it is left alone.
|
|
49
|
+
*/
|
|
50
|
+
export async function advanceReadTargetClock(now = new Date()): Promise<boolean> {
|
|
51
|
+
let advanced = false;
|
|
52
|
+
const advance = coordinatorState.activationQueue.then(() => {
|
|
53
|
+
const target = coordinatorState.activeTarget;
|
|
54
|
+
if (!target || coordinatorState.transitioning) return;
|
|
55
|
+
const view = { effectiveNow: now.toISOString() };
|
|
56
|
+
coordinatorState.activeTarget = { scope: target.scope, view };
|
|
57
|
+
updateApplicationView(view);
|
|
58
|
+
advanced = true;
|
|
59
|
+
});
|
|
60
|
+
coordinatorState.activationQueue = advance.catch(() => undefined);
|
|
61
|
+
await advance;
|
|
62
|
+
return advanced;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runActiveRead<T>(callback: (session: ReadModelSession) => Promise<T>): Promise<T> {
|
|
66
|
+
const target = coordinatorState.activeTarget && !coordinatorState.transitioning
|
|
67
|
+
? coordinatorState.activeTarget
|
|
68
|
+
: await waitForActiveTarget();
|
|
69
|
+
return callback(createReadModelSession(db as unknown as Kysely<ReadModelDatabase>, {
|
|
70
|
+
schema: 'main', scope: target.scope, view: target.view,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function beginReadTargetTransition(): void {
|
|
75
|
+
coordinatorState.error = null;
|
|
76
|
+
beginWorkspaceTransition();
|
|
77
|
+
coordinatorState.transitioning = true;
|
|
78
|
+
beginApplicationViewTransition();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cancelReadTargetTransition(): void {
|
|
82
|
+
coordinatorState.transitioning = false;
|
|
83
|
+
cancelApplicationViewTransition();
|
|
84
|
+
if (!coordinatorState.activeTarget) return;
|
|
85
|
+
const { scope } = coordinatorState.activeTarget;
|
|
86
|
+
completeWorkspaceTransition(JSON.stringify([scope.tenantId, scope.logId]));
|
|
87
|
+
for (const waiter of coordinatorState.waiters.splice(0)) waiter.resolve(coordinatorState.activeTarget);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function failReadTargetTransition(error: unknown): void {
|
|
91
|
+
coordinatorState.error = error;
|
|
92
|
+
coordinatorState.transitioning = true;
|
|
93
|
+
failWorkspaceTransition(error);
|
|
94
|
+
for (const waiter of coordinatorState.waiters.splice(0)) waiter.reject(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function waitForActiveTarget(): Promise<ActiveReadTarget> {
|
|
98
|
+
if (coordinatorState.error) return Promise.reject(coordinatorState.error);
|
|
99
|
+
if (coordinatorState.activeTarget && !coordinatorState.transitioning) {
|
|
100
|
+
return Promise.resolve(coordinatorState.activeTarget);
|
|
101
|
+
}
|
|
102
|
+
return new Promise((resolve, reject) => coordinatorState.waiters.push({ resolve, reject }));
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { invalidate } from '$app/navigation';
|
|
2
|
+
import { waitForPageUpdates } from './page-data.svelte.js';
|
|
3
|
+
import { measurementMetadata, withTiming } from '../utils/perf.js';
|
|
4
|
+
|
|
5
|
+
/** Public refreshes include both deferred queries and the resulting paint. */
|
|
6
|
+
export async function refreshPageData(...targets: string[]): Promise<void> {
|
|
7
|
+
await withTiming('ui', 'refreshPage', async () => {
|
|
8
|
+
await Promise.all(targets.map(target => invalidate(target)));
|
|
9
|
+
await waitForPageUpdates();
|
|
10
|
+
}, {
|
|
11
|
+
...measurementMetadata(), kind: 'refresh', baselineMetric: 'invalidationCost',
|
|
12
|
+
route: typeof location === 'undefined' ? '' : `${location.pathname}${location.search}`,
|
|
13
|
+
targetCount: targets.length,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildNavigation } from '@hearthlog/contracts/features/navigation';
|
|
2
|
+
import { DEVELOPER_PATH, ENTITIES_PATH, featureForPath } from '@hearthlog/contracts/features/registry';
|
|
3
|
+
import type { TenantConfig } from '@hearthlog/contracts/tenant/config';
|
|
4
|
+
|
|
5
|
+
function storageKey(tenantId: string): string {
|
|
6
|
+
return `stor:last-section:${tenantId}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Keep only the feature's entry page, never a date, filter, or drawer. */
|
|
10
|
+
export function sectionForPath(pathname: string, tenant: TenantConfig): string | null {
|
|
11
|
+
if (pathname === DEVELOPER_PATH && tenant.developerNavVisible) return DEVELOPER_PATH;
|
|
12
|
+
if (pathname === ENTITIES_PATH || pathname.startsWith(`${ENTITIES_PATH}/`)) {
|
|
13
|
+
return tenant.entitiesNavVisible ? ENTITIES_PATH : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const feature = featureForPath(pathname);
|
|
17
|
+
if (!feature || !tenant.features.has(feature.id)) return null;
|
|
18
|
+
return feature.page?.path ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defaultSection(tenant: TenantConfig): string {
|
|
22
|
+
const configured = sectionForPath(tenant.home, tenant);
|
|
23
|
+
if (configured) return configured;
|
|
24
|
+
return buildNavigation(tenant).destinations[0]?.path ?? DEVELOPER_PATH;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startSection(tenant: TenantConfig, storage: Pick<Storage, 'getItem'> | null): string {
|
|
28
|
+
try {
|
|
29
|
+
const saved = storage?.getItem(storageKey(tenant.id));
|
|
30
|
+
if (saved) {
|
|
31
|
+
const section = sectionForPath(saved, tenant);
|
|
32
|
+
if (section) return section;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// A blocked browser store still has a usable start route.
|
|
36
|
+
}
|
|
37
|
+
return defaultSection(tenant);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function startTarget(_url: URL, tenant: TenantConfig, storage: Pick<Storage, 'getItem'> | null): string {
|
|
41
|
+
return startSection(tenant, storage);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function rememberSection(
|
|
45
|
+
url: { pathname: string },
|
|
46
|
+
tenant: TenantConfig,
|
|
47
|
+
storage: Pick<Storage, 'setItem'> | null,
|
|
48
|
+
): void {
|
|
49
|
+
const section = sectionForPath(url.pathname, tenant);
|
|
50
|
+
if (!section) return;
|
|
51
|
+
try {
|
|
52
|
+
storage?.setItem(storageKey(tenant.id), section);
|
|
53
|
+
} catch {
|
|
54
|
+
// Navigation remains usable when browser storage is unavailable.
|
|
55
|
+
}
|
|
56
|
+
}
|