@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,93 @@
|
|
|
1
|
+
import { get as getKv, set as setKv } from '../db/kv.js';
|
|
2
|
+
import {
|
|
3
|
+
clearActiveContext,
|
|
4
|
+
getActiveContext,
|
|
5
|
+
setActiveContext,
|
|
6
|
+
type ActiveContext,
|
|
7
|
+
} from '../db/index.js';
|
|
8
|
+
import { getLedgerClient, loadLedgerConfig, type SessionResponse } from '../ledger/index.js';
|
|
9
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
10
|
+
|
|
11
|
+
interface CachedSession extends SessionResponse {
|
|
12
|
+
ledgerOrigin: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getLedgerOrigin(): string {
|
|
16
|
+
const baseUrl = loadLedgerConfig().baseUrl;
|
|
17
|
+
const browserOrigin =
|
|
18
|
+
typeof globalThis.location === 'undefined'
|
|
19
|
+
? 'http://localhost'
|
|
20
|
+
: globalThis.location.origin;
|
|
21
|
+
const url = new URL(baseUrl, browserOrigin);
|
|
22
|
+
return `${url.origin}${url.pathname.replace(/\/$/, '')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function getLedgerCredentialFingerprint(): Promise<string> {
|
|
26
|
+
const { authToken } = loadLedgerConfig();
|
|
27
|
+
const credentialIdentity = authToken ?? `worker-managed:${getLedgerOrigin()}`;
|
|
28
|
+
const bytes = new TextEncoder().encode(credentialIdentity);
|
|
29
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
30
|
+
return [...new Uint8Array(digest)]
|
|
31
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
32
|
+
.join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function getLedgerCacheKey(resource: string): Promise<string> {
|
|
36
|
+
// The origin is stable when a tenant's frontend is redeployed. Include the
|
|
37
|
+
// compiled tenant identity so a pre-isolation cache cannot carry a log or
|
|
38
|
+
// session selection into the newly isolated tenant build.
|
|
39
|
+
return `ledger-${resource}:${tenant.id}:${getLedgerOrigin()}:${await getLedgerCredentialFingerprint()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resolveLedgerSession(): Promise<CachedSession> {
|
|
43
|
+
const cacheKey = await getLedgerCacheKey('session');
|
|
44
|
+
const cached = await getKv<CachedSession>(cacheKey);
|
|
45
|
+
// Reuse only this namespace's verified session when explicitly offline.
|
|
46
|
+
// An online/unknown hint still follows the normal verification policy.
|
|
47
|
+
if (cached && typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
48
|
+
return cached;
|
|
49
|
+
}
|
|
50
|
+
const { authToken } = loadLedgerConfig();
|
|
51
|
+
if (cached && authToken) {
|
|
52
|
+
void getLedgerClient().getSession()
|
|
53
|
+
.then((session) => setKv(cacheKey, { ...session, ledgerOrigin: getLedgerOrigin() }))
|
|
54
|
+
.catch(() => undefined);
|
|
55
|
+
return cached;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const session = await getLedgerClient().getSession();
|
|
60
|
+
const verified = { ...session, ledgerOrigin: getLedgerOrigin() };
|
|
61
|
+
await setKv(cacheKey, verified);
|
|
62
|
+
return verified;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (cached) return cached;
|
|
65
|
+
throw new Error(
|
|
66
|
+
'This Ledger credential must be verified online before its offline data can be opened',
|
|
67
|
+
{ cause: error },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function activateLogContext(logId: string): Promise<ActiveContext> {
|
|
73
|
+
if (!logId) throw new Error('Cannot activate an empty Ledger log ID');
|
|
74
|
+
const session = await resolveLedgerSession();
|
|
75
|
+
const context: ActiveContext = {
|
|
76
|
+
tenantId: session.appId,
|
|
77
|
+
principalId: session.principalId,
|
|
78
|
+
logId,
|
|
79
|
+
ledgerOrigin: session.ledgerOrigin,
|
|
80
|
+
};
|
|
81
|
+
await setActiveContext(context);
|
|
82
|
+
// Reload in-memory dynamic registry for this log (definitions are log-scoped)
|
|
83
|
+
const { loadDynamicEntityTypes } = await import('../event/dynamic-entities.js');
|
|
84
|
+
await loadDynamicEntityTypes();
|
|
85
|
+
return context;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function deactivateLogContext(): Promise<void> {
|
|
89
|
+
await clearActiveContext();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { getActiveContext };
|
|
93
|
+
export { getSessionContext, setSessionContext } from '../db/session-context.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type LocalStorePhase = 'booting' | 'ready' | 'degraded' | 'failed';
|
|
2
|
+
|
|
3
|
+
export interface LocalStoreStatus {
|
|
4
|
+
phase: LocalStorePhase;
|
|
5
|
+
activeLogId: string | null;
|
|
6
|
+
schemaVersion: number | null;
|
|
7
|
+
schemaRebuilt: boolean;
|
|
8
|
+
errorMessage: string | null;
|
|
9
|
+
readyAt: Date | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const localStoreStatus = $state<LocalStoreStatus>({
|
|
13
|
+
phase: 'booting',
|
|
14
|
+
activeLogId: null,
|
|
15
|
+
schemaVersion: null,
|
|
16
|
+
schemaRebuilt: false,
|
|
17
|
+
errorMessage: null,
|
|
18
|
+
readyAt: null,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function markLocalStoreBooting(): void {
|
|
22
|
+
localStoreStatus.phase = 'booting';
|
|
23
|
+
localStoreStatus.errorMessage = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function markLocalStoreReady(input: {
|
|
27
|
+
activeLogId: string;
|
|
28
|
+
schemaVersion: number;
|
|
29
|
+
schemaRebuilt: boolean;
|
|
30
|
+
}): void {
|
|
31
|
+
localStoreStatus.phase = 'ready';
|
|
32
|
+
localStoreStatus.activeLogId = input.activeLogId;
|
|
33
|
+
localStoreStatus.schemaVersion = input.schemaVersion;
|
|
34
|
+
localStoreStatus.schemaRebuilt = input.schemaRebuilt;
|
|
35
|
+
localStoreStatus.errorMessage = null;
|
|
36
|
+
localStoreStatus.readyAt = new Date();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markLocalStoreDegraded(input: {
|
|
40
|
+
message: string;
|
|
41
|
+
schemaVersion: number;
|
|
42
|
+
}): void {
|
|
43
|
+
localStoreStatus.phase = 'degraded';
|
|
44
|
+
localStoreStatus.activeLogId = null;
|
|
45
|
+
localStoreStatus.schemaVersion = input.schemaVersion;
|
|
46
|
+
localStoreStatus.schemaRebuilt = false;
|
|
47
|
+
localStoreStatus.errorMessage = input.message;
|
|
48
|
+
localStoreStatus.readyAt = new Date();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function markLocalStoreFailed(error: unknown): void {
|
|
52
|
+
localStoreStatus.phase = 'failed';
|
|
53
|
+
localStoreStatus.errorMessage = error instanceof Error
|
|
54
|
+
? error.message
|
|
55
|
+
: 'The local database could not be opened';
|
|
56
|
+
localStoreStatus.readyAt = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { db, requireActiveContext } from '../db/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The active log's version as this device sees it: the contiguous accepted head
|
|
5
|
+
* plus the local actions that have no version yet, so saving moves the number
|
|
6
|
+
* before sync catches up. `null` while local data is not open.
|
|
7
|
+
*/
|
|
8
|
+
export const logVersion = $state<{ value: number | null }>({ value: null });
|
|
9
|
+
|
|
10
|
+
export async function refreshLogVersion(logId: string | null): Promise<void> {
|
|
11
|
+
if (!logId) {
|
|
12
|
+
logVersion.value = null;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const context = await requireActiveContext();
|
|
17
|
+
const [head, pending] = await Promise.all([
|
|
18
|
+
db.selectFrom('log_sync_state')
|
|
19
|
+
.select('downloaded_head_version')
|
|
20
|
+
.where('tenant_id', '=', context.tenantId)
|
|
21
|
+
.where('log_id', '=', logId)
|
|
22
|
+
.executeTakeFirst(),
|
|
23
|
+
db.selectFrom('raw_events')
|
|
24
|
+
.select((eb) => eb.fn.count('id').as('count'))
|
|
25
|
+
.where('tenant_id', '=', context.tenantId)
|
|
26
|
+
.where('log_id', '=', logId)
|
|
27
|
+
.where('log_version', 'is', null)
|
|
28
|
+
.where('projection_state', '=', 'effective')
|
|
29
|
+
.executeTakeFirst(),
|
|
30
|
+
]);
|
|
31
|
+
logVersion.value = (head?.downloaded_head_version ?? 0) + Number(pending?.count ?? 0);
|
|
32
|
+
} catch {
|
|
33
|
+
logVersion.value = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getLedgerClient, type LogLineageIndex, type LogListResponse, type LogSummary } from "../ledger/index.js";
|
|
2
|
+
import { get as getKv, set as setKv } from '../db/kv.js';
|
|
3
|
+
import { getLedgerCacheKey } from './local-context.js';
|
|
4
|
+
|
|
5
|
+
class LogsStore {
|
|
6
|
+
#value = $state<LogSummary[]>([]);
|
|
7
|
+
#lineage = $state<LogLineageIndex | null>(null);
|
|
8
|
+
|
|
9
|
+
get value() {
|
|
10
|
+
return this.#value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get lineage() {
|
|
14
|
+
return this.#lineage;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get names() {
|
|
18
|
+
return this.#value.map((log) => log.name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async loadCached() {
|
|
22
|
+
try {
|
|
23
|
+
const cached = await getKv<LogListResponse | LogSummary[]>(await getLedgerCacheKey('logs'));
|
|
24
|
+
if (Array.isArray(cached)) {
|
|
25
|
+
this.#value = cached;
|
|
26
|
+
this.#lineage = null;
|
|
27
|
+
} else if (cached) {
|
|
28
|
+
this.#value = cached.logs;
|
|
29
|
+
this.#lineage = cached.lineage ?? null;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// A missing credential has no safe cache namespace.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Hides or shows a log in the tree. The list updates once Ledger confirms. */
|
|
37
|
+
async setHidden(logId: string, hidden: boolean) {
|
|
38
|
+
const updated = await getLedgerClient().setLogVisibility(logId, hidden);
|
|
39
|
+
this.#value = this.#value.map((log) => (log.id === logId ? { ...log, hiddenAt: updated.hiddenAt ?? null } : log));
|
|
40
|
+
try {
|
|
41
|
+
await setKv(await getLedgerCacheKey('logs'), { logs: this.#value, lineage: this.#lineage ?? undefined });
|
|
42
|
+
} catch {
|
|
43
|
+
// The next refresh rewrites the cache.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async refresh() {
|
|
48
|
+
await this.loadCached();
|
|
49
|
+
try {
|
|
50
|
+
const response = await getLedgerClient().listLogsResponse();
|
|
51
|
+
this.#value = response.logs;
|
|
52
|
+
this.#lineage = response.lineage ?? null;
|
|
53
|
+
await setKv(await getLedgerCacheKey('logs'), response);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// ignore refresh errors; UI will keep last known list
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const logs = new LogsStore();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { del, get, set } from '../db/kv.js';
|
|
2
|
+
import { isLedgerServiceError } from '../ledger/errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A workspace that moved to another origin. The old origin's Ledger answers
|
|
6
|
+
* writes with 410 `tenant_moved`; this device then stops sending, keeps its
|
|
7
|
+
* queued work, and offers to export it (see pending-work.ts). The flag is kept
|
|
8
|
+
* on the device so a reload does not start sending again.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const KEY = 'stor:workspace-moved';
|
|
12
|
+
|
|
13
|
+
export interface MovedWorkspace {
|
|
14
|
+
/** The origin the workspace lives at now, when the old one said so. */
|
|
15
|
+
destination: string | null;
|
|
16
|
+
detectedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const movedWorkspace = $state<{ value: MovedWorkspace | null }>({ value: null });
|
|
20
|
+
|
|
21
|
+
/** The destination named by a "moved" answer, or null when the error is anything else. */
|
|
22
|
+
export function movedDestination(error: unknown): { destination: string | null } | null {
|
|
23
|
+
if (!isLedgerServiceError(error) || error.statusCode !== 410 || error.code !== 'tenant_moved') return null;
|
|
24
|
+
const destination = (error.response as { destination?: unknown } | undefined)?.destination;
|
|
25
|
+
return { destination: typeof destination === 'string' ? destination : null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function markWorkspaceMoved(destination: string | null): Promise<void> {
|
|
29
|
+
if (movedWorkspace.value) return;
|
|
30
|
+
const moved: MovedWorkspace = { destination, detectedAt: new Date().toISOString() };
|
|
31
|
+
movedWorkspace.value = moved;
|
|
32
|
+
await set(KEY, moved).catch(() => undefined);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function restoreMovedWorkspace(): Promise<void> {
|
|
36
|
+
movedWorkspace.value = await get<MovedWorkspace>(KEY).catch(() => null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Sending again after the operator lifted the fence, or when this origin turns out to be the new home. */
|
|
40
|
+
export async function clearMovedWorkspace(): Promise<void> {
|
|
41
|
+
movedWorkspace.value = null;
|
|
42
|
+
await del(KEY).catch(() => undefined);
|
|
43
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export type OfflineReadinessPhase =
|
|
2
|
+
| 'checking'
|
|
3
|
+
| 'installing'
|
|
4
|
+
| 'available'
|
|
5
|
+
| 'unavailable'
|
|
6
|
+
| 'unsupported'
|
|
7
|
+
| 'failed';
|
|
8
|
+
|
|
9
|
+
export interface OfflineReadinessState {
|
|
10
|
+
phase: OfflineReadinessPhase;
|
|
11
|
+
errorMessage: string | null;
|
|
12
|
+
checkedAt: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CacheRequirement {
|
|
16
|
+
label: string;
|
|
17
|
+
path: RegExp;
|
|
18
|
+
contentType: RegExp;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OfflineReadinessDependencies {
|
|
22
|
+
serviceWorker?: Pick<ServiceWorkerContainer, 'getRegistration'> | null;
|
|
23
|
+
cacheStorage?: CacheStorage | null;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const REQUIRED_CORE_CACHE_ENTRIES: readonly CacheRequirement[] = [
|
|
28
|
+
{ label: 'application fallback', path: /\/index\.html$/, contentType: /text\/html/i },
|
|
29
|
+
{
|
|
30
|
+
label: 'application entry',
|
|
31
|
+
path: /\/_app\/immutable\/entry\/app\.[^/]+\.js$/,
|
|
32
|
+
contentType: /(?:java|ecma)script/i,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'SQLocal worker',
|
|
36
|
+
path: /\/_app\/immutable\/workers\/worker-[^/]+\.js$/,
|
|
37
|
+
contentType: /(?:java|ecma)script/i,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'SQLocal worker chunk',
|
|
41
|
+
path: /\/_app\/immutable\/workers\/chunks\/[^/]+\.js$/,
|
|
42
|
+
contentType: /(?:java|ecma)script/i,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'SQLite OPFS proxy',
|
|
46
|
+
path: /\/_app\/immutable\/workers\/sqlite3-opfs-async-proxy-[^/]+\.js$/,
|
|
47
|
+
contentType: /(?:java|ecma)script/i,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: 'SQLite WASM',
|
|
51
|
+
path: /\/_app\/immutable\/workers\/assets\/sqlite3[.-][^/]+\.wasm$/,
|
|
52
|
+
contentType: /application\/wasm/i,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const offlineReadiness = $state<OfflineReadinessState>({
|
|
57
|
+
phase: 'checking',
|
|
58
|
+
errorMessage: null,
|
|
59
|
+
checkedAt: null,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function updateOfflineReadiness(
|
|
63
|
+
phase: OfflineReadinessPhase,
|
|
64
|
+
errorMessage: string | null,
|
|
65
|
+
checkedAt: number | null = Date.now(),
|
|
66
|
+
): void {
|
|
67
|
+
offlineReadiness.phase = phase;
|
|
68
|
+
offlineReadiness.errorMessage = errorMessage;
|
|
69
|
+
offlineReadiness.checkedAt = checkedAt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function markOfflineReadinessInstalling(): void {
|
|
73
|
+
updateOfflineReadiness('installing', null, null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function markOfflineReadinessFailed(error: unknown): void {
|
|
77
|
+
updateOfflineReadiness(
|
|
78
|
+
'failed',
|
|
79
|
+
error instanceof Error ? error.message : 'The offline bundle could not be installed.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function checkOfflineReadiness(
|
|
84
|
+
dependencies: OfflineReadinessDependencies = {},
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const serviceWorker = dependencies.serviceWorker === undefined
|
|
87
|
+
? (typeof navigator !== 'undefined' && 'serviceWorker' in navigator
|
|
88
|
+
? navigator.serviceWorker
|
|
89
|
+
: undefined)
|
|
90
|
+
: dependencies.serviceWorker ?? undefined;
|
|
91
|
+
const cacheStorage = dependencies.cacheStorage === undefined
|
|
92
|
+
? (typeof caches !== 'undefined' ? caches : undefined)
|
|
93
|
+
: dependencies.cacheStorage ?? undefined;
|
|
94
|
+
const now = dependencies.now ?? Date.now;
|
|
95
|
+
|
|
96
|
+
if (!serviceWorker || !cacheStorage) {
|
|
97
|
+
updateOfflineReadiness('unsupported', 'Offline caching is not supported by this browser.', now());
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
updateOfflineReadiness('checking', null, null);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const registration = await serviceWorker.getRegistration();
|
|
105
|
+
if (!registration) {
|
|
106
|
+
updateOfflineReadiness('unavailable', 'No service worker is registered yet.', now());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!registration.active) {
|
|
110
|
+
updateOfflineReadiness('installing', null, now());
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const cacheNames = (await cacheStorage.keys()).filter((name) => name.includes('precache'));
|
|
115
|
+
const cachedRequests = (
|
|
116
|
+
await Promise.all(cacheNames.map(async (cacheName) => {
|
|
117
|
+
const cache = await cacheStorage.open(cacheName);
|
|
118
|
+
return (await cache.keys()).map((request) => ({ cache, request }));
|
|
119
|
+
}))
|
|
120
|
+
).flat();
|
|
121
|
+
|
|
122
|
+
const missing: string[] = [];
|
|
123
|
+
for (const requirement of REQUIRED_CORE_CACHE_ENTRIES) {
|
|
124
|
+
const match = cachedRequests.find(({ request }) =>
|
|
125
|
+
requirement.path.test(new URL(request.url).pathname),
|
|
126
|
+
);
|
|
127
|
+
if (!match) {
|
|
128
|
+
missing.push(requirement.label);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const response = await match.cache.match(match.request);
|
|
133
|
+
if (!response) {
|
|
134
|
+
missing.push(requirement.label);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
139
|
+
if (!requirement.contentType.test(contentType)) {
|
|
140
|
+
updateOfflineReadiness(
|
|
141
|
+
'failed',
|
|
142
|
+
`${requirement.label} has an invalid content type (${contentType || 'missing'}).`,
|
|
143
|
+
now(),
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
updateOfflineReadiness(
|
|
151
|
+
'unavailable',
|
|
152
|
+
`Offline bundle is missing: ${missing.join(', ')}.`,
|
|
153
|
+
now(),
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const { cache, request } of cachedRequests) {
|
|
159
|
+
if (!new URL(request.url).pathname.endsWith('.js')) continue;
|
|
160
|
+
const response = await cache.match(request);
|
|
161
|
+
if (!response) {
|
|
162
|
+
updateOfflineReadiness(
|
|
163
|
+
'unavailable',
|
|
164
|
+
`Offline bundle is missing cached JavaScript: ${new URL(request.url).pathname}.`,
|
|
165
|
+
now(),
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
170
|
+
if (!/(?:java|ecma)script/i.test(contentType)) {
|
|
171
|
+
updateOfflineReadiness(
|
|
172
|
+
'failed',
|
|
173
|
+
`Cached JavaScript has an invalid content type (${contentType || 'missing'}).`,
|
|
174
|
+
now(),
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
updateOfflineReadiness('available', null, now());
|
|
181
|
+
} catch (error) {
|
|
182
|
+
updateOfflineReadiness(
|
|
183
|
+
'failed',
|
|
184
|
+
error instanceof Error ? error.message : 'Offline readiness could not be checked.',
|
|
185
|
+
now(),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AppendEventsRequest, IncomingEvent } from '../ledger/index.js';
|
|
2
|
+
|
|
3
|
+
/** Matches Ledger's append contract (`appendEventsBody` in `packages/ledger/src/schemas.ts`). */
|
|
4
|
+
export const MAX_BATCH_SIZE = 512;
|
|
5
|
+
|
|
6
|
+
export interface PendingBatchEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
entityId: string;
|
|
11
|
+
payload: Record<string, unknown>;
|
|
12
|
+
localSequence: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function prepareAppendRequest(
|
|
16
|
+
events: PendingBatchEvent[],
|
|
17
|
+
currentVersions: ReadonlyMap<string, number>,
|
|
18
|
+
requestId: string = crypto.randomUUID(),
|
|
19
|
+
): AppendEventsRequest {
|
|
20
|
+
if (events.length === 0) throw new Error('Cannot prepare an empty Ledger append');
|
|
21
|
+
if (events.length > MAX_BATCH_SIZE) {
|
|
22
|
+
throw new Error(`Ledger batches are limited to ${MAX_BATCH_SIZE} events`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const nextVersions = new Map(currentVersions);
|
|
26
|
+
const incomingEvents: IncomingEvent[] = events.map((event) => {
|
|
27
|
+
if (!event.id) throw new Error('Every local event must have an ID before it enters the outbox');
|
|
28
|
+
const currentVersion = nextVersions.get(event.entityId);
|
|
29
|
+
const incoming: IncomingEvent = {
|
|
30
|
+
id: event.id,
|
|
31
|
+
type: event.type,
|
|
32
|
+
entityId: event.entityId,
|
|
33
|
+
timestamp: event.timestamp,
|
|
34
|
+
payload: event.payload,
|
|
35
|
+
};
|
|
36
|
+
if (currentVersion !== undefined) incoming.baseEntityVersion = currentVersion;
|
|
37
|
+
nextVersions.set(event.entityId, (currentVersion ?? 0) + 1);
|
|
38
|
+
return incoming;
|
|
39
|
+
});
|
|
40
|
+
return { requestId, events: incomingEvents };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function hashRequest(request: AppendEventsRequest): Promise<string> {
|
|
44
|
+
const bytes = new TextEncoder().encode(JSON.stringify(canonicalize(request)));
|
|
45
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
46
|
+
return [...new Uint8Array(digest)]
|
|
47
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
48
|
+
.join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function canonicalize(value: unknown): unknown {
|
|
52
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
53
|
+
if (value && typeof value === 'object') {
|
|
54
|
+
return Object.fromEntries(
|
|
55
|
+
Object.entries(value as Record<string, unknown>)
|
|
56
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
57
|
+
.map(([key, nested]) => [key, canonicalize(nested)]),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|