@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Version Tracker
|
|
3
|
+
*
|
|
4
|
+
* Tracks entity versions in the local read model to support optimistic locking.
|
|
5
|
+
* Maintains a cache of current entity versions for fast lookups during event appends.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Kysely } from 'kysely';
|
|
9
|
+
import { requireActiveContext, type Database } from '../db/index.js';
|
|
10
|
+
import type { StoredEvent } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tracks entity versions in the local SQLite database
|
|
14
|
+
*/
|
|
15
|
+
export class EntityVersionTracker {
|
|
16
|
+
constructor(private readonly db: Kysely<Database>) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Gets the current version for an entity
|
|
20
|
+
*
|
|
21
|
+
* @param entityId - The entity identifier
|
|
22
|
+
* @returns The current entity version, or null if the entity doesn't exist locally
|
|
23
|
+
*/
|
|
24
|
+
async getCurrentVersion(entityId: string): Promise<number | null> {
|
|
25
|
+
const context = await requireActiveContext();
|
|
26
|
+
const result = await this.db
|
|
27
|
+
.selectFrom('entity_versions')
|
|
28
|
+
.select('current_version')
|
|
29
|
+
.where('tenant_id', '=', context.tenantId)
|
|
30
|
+
.where('log_id', '=', context.logId)
|
|
31
|
+
.where('entity_id', '=', entityId)
|
|
32
|
+
.executeTakeFirst();
|
|
33
|
+
|
|
34
|
+
return result?.current_version ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Updates the entity version after a successful append
|
|
39
|
+
*
|
|
40
|
+
* @param entityId - The entity identifier
|
|
41
|
+
* @param entityVersion - The new entity version
|
|
42
|
+
* @param logVersion - The log version where this entity version was recorded
|
|
43
|
+
*/
|
|
44
|
+
async updateVersion(
|
|
45
|
+
entityId: string,
|
|
46
|
+
entityVersion: number,
|
|
47
|
+
logVersion: number
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
console.log('[VersionTracker] Updating version for entity', entityId, 'to', entityVersion);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const context = await requireActiveContext();
|
|
54
|
+
|
|
55
|
+
await this.db
|
|
56
|
+
.insertInto('entity_versions')
|
|
57
|
+
.values({
|
|
58
|
+
tenant_id: context.tenantId,
|
|
59
|
+
log_id: context.logId,
|
|
60
|
+
entity_id: entityId,
|
|
61
|
+
current_version: entityVersion,
|
|
62
|
+
log_version: logVersion,
|
|
63
|
+
updated_at: now
|
|
64
|
+
})
|
|
65
|
+
.onConflict((oc) =>
|
|
66
|
+
oc.columns(['tenant_id', 'log_id', 'entity_id']).doUpdateSet({
|
|
67
|
+
current_version: entityVersion,
|
|
68
|
+
log_version: logVersion,
|
|
69
|
+
updated_at: now
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
.execute();
|
|
73
|
+
|
|
74
|
+
console.log('[VersionTracker] Version updated successfully');
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('[VersionTracker] Failed to update version for entity', entityId, ':', error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Batch updates entity versions from incoming events
|
|
83
|
+
*
|
|
84
|
+
* @param events - Array of stored events to process
|
|
85
|
+
*/
|
|
86
|
+
async updateVersionsFromEvents(events: StoredEvent[]): Promise<void> {
|
|
87
|
+
if (events.length === 0) {
|
|
88
|
+
console.log('[VersionTracker] No events to update versions from');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('[VersionTracker] Updating versions from', events.length, 'events');
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const context = await requireActiveContext();
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Six columns x 100 events remain below the conservative 999-bind limit.
|
|
98
|
+
// SQLite resolves repeated entities and older notifications monotonically.
|
|
99
|
+
await this.db.transaction().execute(async (trx) => {
|
|
100
|
+
for (let offset = 0; offset < events.length; offset += 100) {
|
|
101
|
+
await trx
|
|
102
|
+
.insertInto('entity_versions')
|
|
103
|
+
.values(events.slice(offset, offset + 100).map(event => ({
|
|
104
|
+
tenant_id: context.tenantId,
|
|
105
|
+
log_id: context.logId,
|
|
106
|
+
entity_id: event.entityId,
|
|
107
|
+
current_version: event.entityVersion,
|
|
108
|
+
log_version: event.version,
|
|
109
|
+
updated_at: now
|
|
110
|
+
})))
|
|
111
|
+
.onConflict((oc) =>
|
|
112
|
+
oc.columns(['tenant_id', 'log_id', 'entity_id']).doUpdateSet(eb => ({
|
|
113
|
+
current_version: eb.fn('max', ['current_version', eb.ref('excluded.current_version')]),
|
|
114
|
+
log_version: eb.fn('max', ['log_version', eb.ref('excluded.log_version')]),
|
|
115
|
+
updated_at: now
|
|
116
|
+
}))
|
|
117
|
+
)
|
|
118
|
+
.execute();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
console.log('[VersionTracker] Version updates completed successfully');
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('[VersionTracker] Failed to update versions from events:', error);
|
|
125
|
+
if (error instanceof Error) {
|
|
126
|
+
console.error('[VersionTracker] Error message:', error.message);
|
|
127
|
+
console.error('[VersionTracker] Error stack:', error.stack);
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Rebuilds the entity version cache from raw_events table
|
|
135
|
+
*
|
|
136
|
+
* This is useful for recovering from cache corruption or initializing
|
|
137
|
+
* the cache after a database migration.
|
|
138
|
+
*/
|
|
139
|
+
async rebuildVersionCache(): Promise<void> {
|
|
140
|
+
console.log('[VersionTracker] Rebuilding version cache from raw_events');
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Clear existing cache
|
|
144
|
+
const context = await requireActiveContext();
|
|
145
|
+
await this.db.deleteFrom('entity_versions')
|
|
146
|
+
.where('tenant_id', '=', context.tenantId)
|
|
147
|
+
.where('log_id', '=', context.logId)
|
|
148
|
+
.execute();
|
|
149
|
+
console.log('[VersionTracker] Cleared existing version cache');
|
|
150
|
+
|
|
151
|
+
// Query all events with entity information, ordered by entity and version
|
|
152
|
+
const events = await this.db
|
|
153
|
+
.selectFrom('raw_events')
|
|
154
|
+
.select(['entity_id', 'entity_version', 'log_version', 'timestamp'])
|
|
155
|
+
.where('tenant_id', '=', context.tenantId)
|
|
156
|
+
.where('log_id', '=', context.logId)
|
|
157
|
+
.where('sync_state', '=', 'synced')
|
|
158
|
+
.where('entity_id', 'is not', null)
|
|
159
|
+
.where('entity_version', 'is not', null)
|
|
160
|
+
.where('log_version', 'is not', null)
|
|
161
|
+
.orderBy('entity_id', 'asc')
|
|
162
|
+
.orderBy('entity_version', 'asc')
|
|
163
|
+
.execute();
|
|
164
|
+
|
|
165
|
+
console.log('[VersionTracker] Found', events.length, 'events with entity version data');
|
|
166
|
+
|
|
167
|
+
if (events.length === 0) {
|
|
168
|
+
console.log('[VersionTracker] No events to rebuild from');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Group by entity and find the maximum version for each
|
|
173
|
+
const entityVersions = new Map<
|
|
174
|
+
string,
|
|
175
|
+
{ currentVersion: number; logVersion: number; updatedAt: number }
|
|
176
|
+
>();
|
|
177
|
+
|
|
178
|
+
for (const event of events) {
|
|
179
|
+
if (event.entity_id && event.entity_version !== null) {
|
|
180
|
+
const existing = entityVersions.get(event.entity_id);
|
|
181
|
+
if (event.log_version === null) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!existing || event.entity_version > existing.currentVersion) {
|
|
186
|
+
entityVersions.set(event.entity_id, {
|
|
187
|
+
currentVersion: event.entity_version,
|
|
188
|
+
logVersion: event.log_version,
|
|
189
|
+
updatedAt: event.timestamp
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('[VersionTracker] Rebuilding cache for', entityVersions.size, 'entities');
|
|
196
|
+
|
|
197
|
+
// Insert all entity versions in a transaction
|
|
198
|
+
if (entityVersions.size > 0) {
|
|
199
|
+
await this.db.transaction().execute(async (trx) => {
|
|
200
|
+
const values = Array.from(entityVersions.entries()).map(
|
|
201
|
+
([entityId, { currentVersion, logVersion, updatedAt }]) => ({
|
|
202
|
+
tenant_id: context.tenantId,
|
|
203
|
+
log_id: context.logId,
|
|
204
|
+
entity_id: entityId,
|
|
205
|
+
current_version: currentVersion,
|
|
206
|
+
log_version: logVersion,
|
|
207
|
+
updated_at: updatedAt
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Insert in batches to avoid hitting SQLite limits
|
|
212
|
+
const batchSize = 100;
|
|
213
|
+
for (let i = 0; i < values.length; i += batchSize) {
|
|
214
|
+
const batch = values.slice(i, i + batchSize);
|
|
215
|
+
await trx.insertInto('entity_versions').values(batch).execute();
|
|
216
|
+
console.log('[VersionTracker] Inserted batch of', batch.length, 'entity versions');
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log('[VersionTracker] Version cache rebuild completed successfully');
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('[VersionTracker] Failed to rebuild version cache:', error);
|
|
224
|
+
if (error instanceof Error) {
|
|
225
|
+
console.error('[VersionTracker] Error message:', error.message);
|
|
226
|
+
console.error('[VersionTracker] Error stack:', error.stack);
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets all entity versions (useful for debugging)
|
|
234
|
+
*
|
|
235
|
+
* @returns Map of entity IDs to their current versions
|
|
236
|
+
*/
|
|
237
|
+
async getAllVersions(): Promise<Map<string, number>> {
|
|
238
|
+
const context = await requireActiveContext();
|
|
239
|
+
const results = await this.db
|
|
240
|
+
.selectFrom('entity_versions')
|
|
241
|
+
.select(['entity_id', 'current_version'])
|
|
242
|
+
.where('tenant_id', '=', context.tenantId)
|
|
243
|
+
.where('log_id', '=', context.logId)
|
|
244
|
+
.execute();
|
|
245
|
+
|
|
246
|
+
return new Map(results.map((r) => [r.entity_id, r.current_version]));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Clears all entity version data (useful for testing)
|
|
251
|
+
*/
|
|
252
|
+
async clear(): Promise<void> {
|
|
253
|
+
const context = await requireActiveContext();
|
|
254
|
+
await this.db.deleteFrom('entity_versions')
|
|
255
|
+
.where('tenant_id', '=', context.tenantId)
|
|
256
|
+
.where('log_id', '=', context.logId)
|
|
257
|
+
.execute();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { invalidate } from "$app/navigation";
|
|
2
|
+
import { MODULE_REFRESH_KEYS } from '#generated/modules';
|
|
3
|
+
import {
|
|
4
|
+
getLedgerClient,
|
|
5
|
+
type CreateLogResponse,
|
|
6
|
+
type LogSummary,
|
|
7
|
+
} from "./ledger/index.js";
|
|
8
|
+
import { db, requireActiveContext } from './db/index.js';
|
|
9
|
+
import { eventLogFilename } from "./store/event-log.svelte.js";
|
|
10
|
+
import { logs } from "./store/logs.svelte.js";
|
|
11
|
+
import {
|
|
12
|
+
loadStoredLogSnapshot,
|
|
13
|
+
runSyncCoordinator,
|
|
14
|
+
stopRealtime,
|
|
15
|
+
syncStatus,
|
|
16
|
+
} from "./store/sync.svelte.js";
|
|
17
|
+
import {
|
|
18
|
+
beginReadTargetTransition,
|
|
19
|
+
cancelReadTargetTransition,
|
|
20
|
+
} from "./runtime/read-coordinator.js";
|
|
21
|
+
import { validateNewLogName } from "./log-validation.js";
|
|
22
|
+
|
|
23
|
+
async function invalidateAllViews(): Promise<void> {
|
|
24
|
+
await Promise.all([
|
|
25
|
+
...MODULE_REFRESH_KEYS.map((key) => invalidate(key)),
|
|
26
|
+
invalidate("app:entities"),
|
|
27
|
+
invalidate("app:entity-types"),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resume copying inherited private files; safe to call after an interrupted tab. */
|
|
32
|
+
export async function prepareForkFiles(logId: string, onProgress?: (message: string) => void): Promise<void> {
|
|
33
|
+
let status = 'preparing';
|
|
34
|
+
while (status !== 'ready') {
|
|
35
|
+
const response = await fetch(`/api/log-forks/v1/${logId}/prepare`, { method: 'POST' });
|
|
36
|
+
const result = await response.json() as { status?: string; error?: string; remaining?: number };
|
|
37
|
+
if (!response.ok) throw new Error(result.error ?? 'Could not prepare this fork.');
|
|
38
|
+
status = result.status ?? 'preparing';
|
|
39
|
+
if (status !== 'ready') {
|
|
40
|
+
if (status !== 'preparing' || !Number.isSafeInteger(result.remaining) || (result.remaining ?? 0) < 1) {
|
|
41
|
+
throw new Error('Fork preparation did not report progress. Try again.');
|
|
42
|
+
}
|
|
43
|
+
onProgress?.(`Preparing documents… ${result.remaining} remaining`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create a server log and make its empty read model active in this tab. */
|
|
49
|
+
export async function createAndActivateLog(name: string): Promise<CreateLogResponse> {
|
|
50
|
+
const normalized = validateNewLogName(name, logs.value);
|
|
51
|
+
const response = await getLedgerClient().createLog({ name: normalized });
|
|
52
|
+
|
|
53
|
+
await logs.refresh();
|
|
54
|
+
|
|
55
|
+
let transitionStarted = false;
|
|
56
|
+
try {
|
|
57
|
+
beginReadTargetTransition();
|
|
58
|
+
transitionStarted = true;
|
|
59
|
+
await stopRealtime();
|
|
60
|
+
await eventLogFilename.set(response.id, response.name || normalized);
|
|
61
|
+
await loadStoredLogSnapshot(response.id, []);
|
|
62
|
+
await runSyncCoordinator("manual");
|
|
63
|
+
await invalidateAllViews();
|
|
64
|
+
return response;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (transitionStarted) cancelReadTargetTransition();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fork a synchronized accepted prefix, prepare private files, then activate it locally. */
|
|
72
|
+
export async function forkAndActivateLog(input: {
|
|
73
|
+
name: string;
|
|
74
|
+
targetVersion: number;
|
|
75
|
+
expectedSourceHeadVersion: number;
|
|
76
|
+
requestId: string;
|
|
77
|
+
onProgress?: (message: string) => void;
|
|
78
|
+
}): Promise<LogSummary> {
|
|
79
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) throw new Error('Connect to fork this log.');
|
|
80
|
+
const context = await requireActiveContext();
|
|
81
|
+
const sourceLogId = context.logId;
|
|
82
|
+
const normalized = validateNewLogName(input.name, logs.value.filter(log => log.status !== 'preparing'));
|
|
83
|
+
input.onProgress?.('Checking accepted changes…');
|
|
84
|
+
await runSyncCoordinator('manual');
|
|
85
|
+
const pending = await db.selectFrom('raw_events').select('id')
|
|
86
|
+
.where('tenant_id', '=', context.tenantId).where('log_id', '=', sourceLogId)
|
|
87
|
+
.where(eb => eb.or([eb('projection_state', '=', 'pending_ddl'), eb.and([
|
|
88
|
+
eb('log_version', 'is', null), eb('sync_state', 'in', ['pending', 'conflicted']),
|
|
89
|
+
])])).executeTakeFirst();
|
|
90
|
+
const progress = await db.selectFrom('log_sync_state').selectAll()
|
|
91
|
+
.where('tenant_id', '=', context.tenantId).where('log_id', '=', sourceLogId).executeTakeFirst();
|
|
92
|
+
if (pending || syncStatus.pendingUploads > 0 || !progress
|
|
93
|
+
|| progress.downloaded_head_version !== progress.projected_head_version
|
|
94
|
+
|| progress.projected_head_version !== input.expectedSourceHeadVersion) {
|
|
95
|
+
throw new Error('Finish synchronizing this log, then review the fork point again.');
|
|
96
|
+
}
|
|
97
|
+
const client = getLedgerClient();
|
|
98
|
+
input.onProgress?.('Creating the new log…');
|
|
99
|
+
const fork = await client.forkLog(sourceLogId, {
|
|
100
|
+
name: normalized,
|
|
101
|
+
targetVersion: input.targetVersion,
|
|
102
|
+
expectedSourceHeadVersion: input.expectedSourceHeadVersion,
|
|
103
|
+
requestId: input.requestId,
|
|
104
|
+
});
|
|
105
|
+
input.onProgress?.('Preparing documents…');
|
|
106
|
+
if (fork.status !== 'ready') await prepareForkFiles(fork.id, input.onProgress);
|
|
107
|
+
input.onProgress?.('Opening the new log…');
|
|
108
|
+
await logs.refresh();
|
|
109
|
+
let transitionStarted = false;
|
|
110
|
+
try {
|
|
111
|
+
beginReadTargetTransition();
|
|
112
|
+
transitionStarted = true;
|
|
113
|
+
await stopRealtime();
|
|
114
|
+
await eventLogFilename.set(fork.id, fork.name);
|
|
115
|
+
await loadStoredLogSnapshot(fork.id, []);
|
|
116
|
+
await runSyncCoordinator('manual');
|
|
117
|
+
await invalidateAllViews();
|
|
118
|
+
return { ...fork, status: 'ready' };
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (transitionStarted) cancelReadTargetTransition();
|
|
121
|
+
throw new Error(`The new log is ready, but could not be opened. Select it from the log switcher. ${cause instanceof Error ? cause.message : ''}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LogSummary } from "./ledger/index.js";
|
|
2
|
+
|
|
3
|
+
function logNamesMatch(left: string, right: string): boolean {
|
|
4
|
+
return left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function validateNewLogName(
|
|
8
|
+
name: string,
|
|
9
|
+
existingLogs: readonly LogSummary[],
|
|
10
|
+
): string {
|
|
11
|
+
const normalized = name.trim();
|
|
12
|
+
if (!normalized) throw new Error("Enter a log name.");
|
|
13
|
+
|
|
14
|
+
if (existingLogs.some((log) => logNamesMatch(log.name, normalized))) {
|
|
15
|
+
throw new Error(`A log named "${normalized}" already exists.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { goto } from '$app/navigation';
|
|
2
|
+
import { buildNavigation } from '@hearthlog/contracts/features/navigation';
|
|
3
|
+
import { FEATURES } from '@hearthlog/contracts/features/registry';
|
|
4
|
+
import { onShellEvent, shell, shellApi, type ShellMenuEntry } from './shell.js';
|
|
5
|
+
import { workspaceIsReady, workspaceReadiness } from '@hearthlog/ui/state/readiness.js';
|
|
6
|
+
import { appClock } from '../runtime/view.js';
|
|
7
|
+
import { attention } from '../store/attention.svelte.js';
|
|
8
|
+
import { eventLogFilename } from '../store/event-log.svelte.js';
|
|
9
|
+
import { events } from '../store/events.svelte.js';
|
|
10
|
+
import { notificationState } from '@hearthlog/ui/state/notifications.svelte.js';
|
|
11
|
+
import { syncStatus } from '../store/sync.svelte.js';
|
|
12
|
+
import { APPEARANCE_EVENT, appearance } from '../theme/appearance.svelte.js';
|
|
13
|
+
import { isFeatureEnabled, tenant } from '@hearthlog/contracts/tenant/config';
|
|
14
|
+
|
|
15
|
+
const REMINDER_DEBOUNCE_MS = 1_500;
|
|
16
|
+
/** An in-app route: absolute, same origin, never `//host`. */
|
|
17
|
+
function isAppPath(path: unknown): path is string {
|
|
18
|
+
return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function menuEntries(): { destinations: ShellMenuEntry[]; actions: ShellMenuEntry[] } {
|
|
22
|
+
const destinations = buildNavigation(tenant).sections.map(({ id, label, path, children }) => ({
|
|
23
|
+
id,
|
|
24
|
+
title: label,
|
|
25
|
+
path,
|
|
26
|
+
children: children?.map((child) => ({ id: child.id, title: child.label, path: child.href })),
|
|
27
|
+
}));
|
|
28
|
+
const actions = FEATURES
|
|
29
|
+
.filter((feature) => isFeatureEnabled(feature.id))
|
|
30
|
+
.flatMap((feature) => feature.commands.map((command) => ({
|
|
31
|
+
id: command.id,
|
|
32
|
+
title: command.label,
|
|
33
|
+
path: command.href ?? `${feature.page?.path ?? '/'}${command.query ?? ''}`,
|
|
34
|
+
})));
|
|
35
|
+
return { destinations, actions };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function refreshReminders(): Promise<void> {
|
|
39
|
+
if (!workspaceIsReady()) return;
|
|
40
|
+
shellApi.reminders(await attention.reminders(appClock.now()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function reportAppearance(): void {
|
|
44
|
+
const background = getComputedStyle(document.documentElement).getPropertyValue('--stor-bg').trim();
|
|
45
|
+
shellApi.appearance(appearance.preference, appearance.resolved, background);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where the Mac window controls belong. The sidebar's brand row reserves an empty
|
|
50
|
+
* `[data-stor-lights]` anchor for them, so the layout, not this code, decides the
|
|
51
|
+
* position; it follows the sidebar while it opens, closes and resizes.
|
|
52
|
+
*/
|
|
53
|
+
function trackWindowControls(): () => void {
|
|
54
|
+
let frame = 0;
|
|
55
|
+
const report = () => {
|
|
56
|
+
frame = 0;
|
|
57
|
+
const anchor = document.querySelector('[data-stor-lights]');
|
|
58
|
+
const rect = anchor?.getBoundingClientRect();
|
|
59
|
+
if (!rect || rect.width === 0) return;
|
|
60
|
+
shellApi.lights(Math.round(rect.left * 2) / 2, rect.top + rect.height / 2);
|
|
61
|
+
};
|
|
62
|
+
const schedule = () => { frame ||= requestAnimationFrame(report); };
|
|
63
|
+
const observer = new ResizeObserver(schedule);
|
|
64
|
+
observer.observe(document.documentElement);
|
|
65
|
+
const sidebar = document.querySelector('[data-testid="desktop-sidebar"]');
|
|
66
|
+
if (sidebar) observer.observe(sidebar);
|
|
67
|
+
schedule();
|
|
68
|
+
return () => {
|
|
69
|
+
observer.disconnect();
|
|
70
|
+
if (frame) cancelAnimationFrame(frame);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Connects the page to the native shell when there is one: native chrome follows the
|
|
76
|
+
* theme, menus and the app badge mirror what the page knows, reminders are scheduled
|
|
77
|
+
* on the device, and the shell can open routes. In a browser this returns at once.
|
|
78
|
+
*/
|
|
79
|
+
export function startNativeShell({ toggleSidebar }: { toggleSidebar: () => void }): (() => void) | undefined {
|
|
80
|
+
if (!shell) return undefined;
|
|
81
|
+
const cleanups: Array<() => void> = [];
|
|
82
|
+
|
|
83
|
+
cleanups.push(onShellEvent((event) => {
|
|
84
|
+
if (event.type === 'navigate') {
|
|
85
|
+
if (isAppPath(event.path)) void goto(event.path);
|
|
86
|
+
} else if (event.type === 'toggle-sidebar') {
|
|
87
|
+
toggleSidebar();
|
|
88
|
+
} else if (event.type === 'resume') {
|
|
89
|
+
scheduleReminders();
|
|
90
|
+
}
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
window.addEventListener(APPEARANCE_EVENT, reportAppearance);
|
|
94
|
+
cleanups.push(() => window.removeEventListener(APPEARANCE_EVENT, reportAppearance));
|
|
95
|
+
reportAppearance();
|
|
96
|
+
|
|
97
|
+
const { destinations, actions } = menuEntries();
|
|
98
|
+
shellApi.menu(destinations, actions);
|
|
99
|
+
|
|
100
|
+
if (shell.kind === 'mac') {
|
|
101
|
+
cleanups.push(trackWindowControls());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let reminderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
const scheduleReminders = () => {
|
|
106
|
+
clearTimeout(reminderTimer);
|
|
107
|
+
reminderTimer = setTimeout(() => { void refreshReminders().catch(() => undefined); }, REMINDER_DEBOUNCE_MS);
|
|
108
|
+
};
|
|
109
|
+
cleanups.push(() => clearTimeout(reminderTimer));
|
|
110
|
+
// The date moves on while the app sleeps, so plan again whenever it wakes.
|
|
111
|
+
const onVisible = () => { if (document.visibilityState === 'visible') scheduleReminders(); };
|
|
112
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
113
|
+
cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));
|
|
114
|
+
cleanups.push(workspaceReadiness.subscribe((state) => { if (state.ready) scheduleReminders(); }));
|
|
115
|
+
|
|
116
|
+
let lastHaptic = '';
|
|
117
|
+
cleanups.push($effect.root(() => {
|
|
118
|
+
$effect(() => { shellApi.badge(attention.total); });
|
|
119
|
+
$effect(() => { shellApi.outbox(syncStatus.pendingUploads); });
|
|
120
|
+
$effect(() => {
|
|
121
|
+
const latest = notificationState.items.at(-1);
|
|
122
|
+
if (!latest || latest.id === lastHaptic) return;
|
|
123
|
+
lastHaptic = latest.id;
|
|
124
|
+
if (latest.type === 'success') shellApi.haptic('success');
|
|
125
|
+
else if (latest.type === 'error') shellApi.haptic('warning');
|
|
126
|
+
});
|
|
127
|
+
// Re-plan after every event change and log switch.
|
|
128
|
+
$effect(() => {
|
|
129
|
+
void events.updatedAt;
|
|
130
|
+
void eventLogFilename.value;
|
|
131
|
+
scheduleReminders();
|
|
132
|
+
});
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
return () => cleanups.forEach((cleanup) => cleanup());
|
|
136
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge to the native Mac and iOS shell (`apple/`).
|
|
3
|
+
*
|
|
4
|
+
* The shell is a thin WKWebView host that loads this same site. It defines
|
|
5
|
+
* `window.storShell` before any page script runs and listens on the `stor`
|
|
6
|
+
* script message handler. In a browser both are absent, so every function here
|
|
7
|
+
* does nothing and callers never branch on the platform.
|
|
8
|
+
*
|
|
9
|
+
* Only small values the page already shows cross the bridge: counts, dates,
|
|
10
|
+
* titles and screen positions. Events, rows and credentials never do.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type ShellKind = 'mac' | 'ios';
|
|
14
|
+
|
|
15
|
+
export interface ShellInfo {
|
|
16
|
+
readonly kind: ShellKind;
|
|
17
|
+
readonly site: string;
|
|
18
|
+
readonly version: number;
|
|
19
|
+
/** Mac: points the window buttons take, so the sidebar can leave exactly that much room. */
|
|
20
|
+
readonly lights?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ShellMenuEntry {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
path: string;
|
|
27
|
+
/** A section's sub-pages; the Mac Dock menu shows them as a submenu. */
|
|
28
|
+
children?: readonly ShellMenuEntry[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ShellReminder {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
body: string;
|
|
35
|
+
/** Local wall-clock time, `YYYY-MM-DDTHH:mm`; the device's time zone applies. */
|
|
36
|
+
at: string;
|
|
37
|
+
/** Route opened when the notification is tapped. */
|
|
38
|
+
path: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Events the shell sends to the page (`window` event `stor-shell`). */
|
|
42
|
+
export type ShellEvent =
|
|
43
|
+
| { type: 'navigate'; path: string }
|
|
44
|
+
| { type: 'toggle-sidebar' }
|
|
45
|
+
| { type: 'resume' };
|
|
46
|
+
|
|
47
|
+
export const SHELL_EVENT = 'stor-shell';
|
|
48
|
+
|
|
49
|
+
declare global {
|
|
50
|
+
interface Window {
|
|
51
|
+
storShell?: ShellInfo;
|
|
52
|
+
webkit?: { messageHandlers?: { stor?: { postMessage(message: unknown): void } } };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const shell: ShellInfo | null = typeof window === 'undefined' ? null : (window.storShell ?? null);
|
|
57
|
+
|
|
58
|
+
function post(type: string, body: Record<string, unknown> = {}): void {
|
|
59
|
+
if (!shell) return;
|
|
60
|
+
try {
|
|
61
|
+
window.webkit?.messageHandlers?.stor?.postMessage({ type, body });
|
|
62
|
+
} catch {
|
|
63
|
+
// The shell is advisory: a failed message never blocks the page.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const shellApi = {
|
|
68
|
+
/** Lets native chrome (window material, status bar, sheets) follow the page's theme. */
|
|
69
|
+
appearance(preference: 'light' | 'dark' | 'system', resolved: 'light' | 'dark', background: string): void {
|
|
70
|
+
post('appearance', { preference, resolved, background });
|
|
71
|
+
},
|
|
72
|
+
/** App icon badge: late work, the same count as the navigation badges. */
|
|
73
|
+
badge(count: number): void {
|
|
74
|
+
post('badge', { count });
|
|
75
|
+
},
|
|
76
|
+
/** Replaces every scheduled reminder with this list. */
|
|
77
|
+
reminders(items: readonly ShellReminder[]): void {
|
|
78
|
+
post('reminders', { items });
|
|
79
|
+
},
|
|
80
|
+
/** Menu bar entries (Mac): the sections the tenant has, and its create shortcuts. */
|
|
81
|
+
menu(destinations: readonly ShellMenuEntry[], actions: readonly ShellMenuEntry[]): void {
|
|
82
|
+
post('menu', { destinations, actions });
|
|
83
|
+
},
|
|
84
|
+
/** Where the window controls belong: left edge of the first one and the vertical centre, in CSS pixels. */
|
|
85
|
+
lights(x: number, y: number): void {
|
|
86
|
+
post('lights', { x, y });
|
|
87
|
+
},
|
|
88
|
+
/** Changes still waiting to upload; iOS keeps the app alive a little longer while there are some. */
|
|
89
|
+
outbox(pending: number): void {
|
|
90
|
+
post('outbox', { pending });
|
|
91
|
+
},
|
|
92
|
+
haptic(kind: 'success' | 'warning'): void {
|
|
93
|
+
post('haptic', { kind });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function onShellEvent(handler: (event: ShellEvent) => void): () => void {
|
|
98
|
+
if (typeof window === 'undefined') return () => {};
|
|
99
|
+
const listener = (event: Event) => handler((event as CustomEvent<ShellEvent>).detail);
|
|
100
|
+
window.addEventListener(SHELL_EVENT, listener);
|
|
101
|
+
return () => window.removeEventListener(SHELL_EVENT, listener);
|
|
102
|
+
}
|