@bitcobblers/wod-wiki-storage 0.0.0-stage → 0.35.2115

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.
@@ -0,0 +1,489 @@
1
+ import { ScriptBlock, EventRecord, FieldCatalogEntry, FieldSourceRecord, FieldValueRecord, CatalogBackfillState } from '@bitcobblers/wod-wiki-core';
2
+ import { IEffort } from '@bitcobblers/wod-wiki-lang';
3
+
4
+ /**
5
+ * Storage Types — V4 Multi-Source Data Lens
6
+ *
7
+ * Defines the data model for the hierarchical IndexedDB storage.
8
+ * V4 replaces the old scripts + section_history stores with a unified
9
+ * `segments` store and adds `attachments` + `analytics` stores.
10
+ *
11
+ * Canonical home of the entity model (moved verbatim from
12
+ * apps/playground/src/types/storage.ts).
13
+ */
14
+
15
+ type SegmentDataType = 'script' | 'youtube' | 'markdown' | 'header' | 'frontmatter' | 'wod' | 'title' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
16
+ /**
17
+ * Note: the slim V11 container. Identity + routing + grouping only.
18
+ * Content lives in `segments` (ordered by position, reconstructed at read);
19
+ * journal grouping lives on the `page` store via pageId; tags live in
20
+ * `tags`/`note_tags`. Legacy fields (rawContent, segmentIds, journalDate,
21
+ * clonedIds, createdFrom, updatedAt, targetDate, templateId) were removed in
22
+ * V11 — see indexeddb-storage-and-page-queries.md.
23
+ */
24
+ type NoteKind = 'journal' | 'template' | 'playground' | 'feed' | 'dashboard' | 'page' | (string & {});
25
+ interface Note {
26
+ id: string;
27
+ title: string;
28
+ /** Domain date of the record (e.g. workout date, publish date) */
29
+ date?: number;
30
+ createdAt: number;
31
+ tags?: string[];
32
+ type?: NoteKind;
33
+ /** N-10 — the note this one was created from (template/collection source).
34
+ * Renamed from templateId. */
35
+ sourceId?: string;
36
+ /** Catalog id for static notes (the directory under markdown/collections
37
+ * or markdown/feeds, with the `feeds/` wrapper stripped). Synthesised by
38
+ * QueryService.staticNotesFromBlocks; undefined for journal notes. */
39
+ catalog?: string;
40
+ /** Set on rows the Seed Import owns. Absent ≡ user-owned: the importer
41
+ * only ever overwrites or deletes rows with seedOrigin === 'seed'. The
42
+ * edit path flips this to 'user' on first user change (phase 3). */
43
+ seedOrigin?: 'seed' | 'user';
44
+ /** manifest.version of the last seed write (seed rows only). */
45
+ seedVersion?: number;
46
+ /** The seed chunk this row came from (seed rows only). */
47
+ seedChunkId?: string;
48
+ /** Original seed file path (e.g. `markdown/collections/girls/fran.md`) — the
49
+ * deterministic UUID is derived from this path; retained for seed content
50
+ * reconstruction (canvas routes, collection READMEs, block-index lookup). */
51
+ sourcePath?: string;
52
+ }
53
+ /**
54
+ * Page: a grouped collection of notes. Two flavors:
55
+ * - calendar page: `date` set (YYYY-MM-DD) — one per journal date.
56
+ * - custom page: `slug` set — name/slug lookup for grouped collections.
57
+ */
58
+ interface Page {
59
+ id: string;
60
+ date?: string;
61
+ slug?: string;
62
+ title?: string;
63
+ createdAt: number;
64
+ }
65
+ interface PageNote {
66
+ id: string;
67
+ pageId: string;
68
+ noteId: string;
69
+ position?: number;
70
+ createdAt: number;
71
+ }
72
+ type TagType = 'template' | 'playground' | 'qualification' | 'notebook' | 'general' | (string & {});
73
+ interface TagTypeRecord {
74
+ id: string;
75
+ name: string;
76
+ label: string;
77
+ color?: string;
78
+ createdAt: number;
79
+ }
80
+ interface Tag {
81
+ id: string;
82
+ label: string;
83
+ type?: TagType;
84
+ createdAt: number;
85
+ }
86
+ interface NoteTag {
87
+ id: string;
88
+ noteId: string;
89
+ tagId: string;
90
+ }
91
+ /**
92
+ * NoteSegment: A versioned chunk of note content.
93
+ * The compound key is [id, version] so every edit creates a new row.
94
+ */
95
+ interface NoteSegment {
96
+ id: string;
97
+ version: number;
98
+ noteId: string;
99
+ /** V11 — ordinal within the parent note (document order). Backfilled from
100
+ * the removed note.segmentIds array. */
101
+ position?: number;
102
+ /** @deprecated V10 legacy placement field. Placements are owned exclusively by `page_notes`. */
103
+ pageId?: string;
104
+ dataType: SegmentDataType;
105
+ data: ScriptBlock | null;
106
+ rawContent: string;
107
+ /** Exact document fragment, including its following line separator. Absent on legacy rows. */
108
+ sourceContent?: string;
109
+ createdAt: number;
110
+ /** V10 — last time this incarnation was touched (defaults to createdAt). */
111
+ updatedAt?: number;
112
+ /** V10 — true for superseded versions; false for the latest per id. */
113
+ isHistory?: boolean;
114
+ }
115
+ /**
116
+ * Derived projection of a NoteSegment into queryable block-index fields.
117
+ * Canonical source is the `segments` store; this store is disposable and can
118
+ * be rebuilt by backfillV14. One row per non-history segment.
119
+ */
120
+ interface BlockIndexRow {
121
+ /** Composite key: `${noteId}:${segmentId}:${segmentVersion}` */
122
+ id: string;
123
+ noteId: string;
124
+ segmentId: string;
125
+ segmentVersion: number;
126
+ /** Ordinal within the parent note (document order). */
127
+ position?: number;
128
+ /** Segment data type: 'wod' | 'h1'..'h6' | 'markdown' | 'frontmatter'. */
129
+ dataType: string;
130
+ /** Content-stable identity for wod blocks (FNV-1a hash); undefined for prose. */
131
+ blockContentId?: string;
132
+ /** Searchable snippet — the segment's raw markdown text. */
133
+ rawContent: string;
134
+ /** Denormalized note title for display. */
135
+ noteTitle: string;
136
+ /** When the segment version was saved. */
137
+ createdAt: number;
138
+ /** True for bundled static content (collections, feeds); false for user journal. */
139
+ isStatic?: boolean;
140
+ /** Original source identifier for static files. */
141
+ sourceId?: string;
142
+ /** Full corpus source path (e.g. `markdown/collections/girls/fran.md`) —
143
+ * the exact join key to `Note.sourcePath`. The seed importer maps
144
+ * `noteId` to the imported note UUID through it; missing or ambiguous
145
+ * mappings abort the import. */
146
+ sourcePath?: string;
147
+ /** Legacy route identity the compiler used before noteId became a real
148
+ * note UUID (path stem, feed route, canvas route). Retained as route
149
+ * sugar for deep links — never a storage join key. */
150
+ routeId?: string;
151
+ }
152
+ /**
153
+ * Relational junction linking blocks to the exercises they contain.
154
+ * Populated by `rebuildBlockIndexForNote` (user notes) and the seed compiler.
155
+ * Enables O(1) bidirectional lookups without scanning raw markdown.
156
+ */
157
+ interface BlockEffort {
158
+ /** Compound key: `${noteId}:${segmentId}:${effortSlug}` or `static:${noteId}:${segmentId}:${effortSlug}` */
159
+ id: string;
160
+ /** Parent note UUID */
161
+ noteId: string;
162
+ /** Positional block ID within the note (NoteSegment.id) */
163
+ blockId: string;
164
+ /** Content-stable hash of the block (SHA-256/FNV-1a) */
165
+ blockContentId?: string;
166
+ /** Canonical exercise slug (e.g. 'thruster', 'pull-up', 'clean-and-jerk') */
167
+ effortSlug: string;
168
+ /** True for bundled static content (collections, feeds); false for user journal. */
169
+ isStatic?: boolean;
170
+ /** Full corpus source path of the containing block's file — the seed
171
+ * importer maps `noteId` to the imported note UUID through it. */
172
+ sourcePath?: string;
173
+ /** When the row was saved. */
174
+ createdAt: number;
175
+ }
176
+ /**
177
+ * ResultOrigin: which app surface produced a Session / AnalyticsDataPoint.
178
+ * 'playground' rows are recorded and viewable but excluded from default
179
+ * journal/progress list filters. Absent on legacy rows — treated as 'journal'.
180
+ */
181
+ type ResultOrigin = 'journal' | 'playground' | 'user';
182
+ /**
183
+ * Session: A pure execution metadata record (renamed from Session; table results -> sessions in DB V20, flattened in V21).
184
+ * Statements and metrics stream directly to `EventRecord` rows in the `events` table.
185
+ */
186
+ interface Session {
187
+ id: string;
188
+ /** Positional identity — FK to NoteSegment.id (the section id of the block run). */
189
+ segmentId?: string;
190
+ /** Version of the NoteSegment at record time. Undefined on legacy rows. */
191
+ segmentVersion?: number;
192
+ noteId: string;
193
+ /** Section position identity — which block in the note this result belongs to. */
194
+ blockId?: string;
195
+ /** Content-stable identity — hash of the fenced content at recording time. */
196
+ blockContentId?: string;
197
+ /** LEGACY — content generation from the retired computeVersion() path.
198
+ * New rows carry segmentVersion instead; retained for pre-existing rows. */
199
+ version?: number;
200
+ /** Which surface produced this result; default filters exclude 'playground'. */
201
+ origin?: ResultOrigin;
202
+ /** Write-path lifecycle (unified event store): row born 'in-progress' at
203
+ * workout start, flipped to 'completed' at finalize. Absent = 'completed'
204
+ * (legacy rows predate streaming). */
205
+ status?: 'in-progress' | 'completed';
206
+ /** @deprecated V10 legacy placement field. Note placements are owned exclusively by `page_notes`. */
207
+ pageId?: string;
208
+ /** When workout started */
209
+ startTime: number;
210
+ /** When workout ended */
211
+ endTime: number;
212
+ /** Total elapsed time (ms) */
213
+ duration: number;
214
+ /** Rounds completed (for rounds-based workouts) */
215
+ roundsCompleted?: number;
216
+ /** Total rounds (for rounds-based workouts) */
217
+ totalRounds?: number;
218
+ /** Reps completed (for rep-based workouts) */
219
+ repsCompleted?: number;
220
+ /** Whether workout was completed or stopped early */
221
+ completed: boolean;
222
+ createdAt: number;
223
+ }
224
+ /**
225
+ * Attachment: Temporal blob data attached to a workout (HR, GPS, etc.).
226
+ */
227
+ interface Attachment {
228
+ id: string;
229
+ noteId: string;
230
+ /** @deprecated V10 legacy placement field. Note placements are owned exclusively by `page_notes`. */
231
+ pageId?: string;
232
+ /** V10 — the Session this blob belongs to, when known. */
233
+ resultId?: string;
234
+ mimeType: string;
235
+ label: string;
236
+ data: ArrayBuffer | string;
237
+ timeSpan: {
238
+ start: number;
239
+ end: number;
240
+ };
241
+ createdAt: number;
242
+ }
243
+
244
+ /**
245
+ * Membership: the account/profile row behind user-owned records. In
246
+ * IndexedDB mode there is exactly one membership (id DEFAULT_MEMBERSHIP_ID)
247
+ * and its source of truth is localStorage — this store exists for schema
248
+ * parity and API mode, where the server keeps the membership table (and
249
+ * wipe() must never destroy it — StoreDef.system). Body metrics are stored
250
+ * as entered (unit-tagged, no normalization); age is derived from birthDate
251
+ * at display time.
252
+ */
253
+ interface Membership {
254
+ id: string;
255
+ displayName: string;
256
+ /** Profile picture: client-resized JPEG data URL (≤256px, ~tens of KB). */
257
+ picture?: string;
258
+ weight?: number;
259
+ weightUnit?: 'kg' | 'lb';
260
+ height?: number;
261
+ heightUnit?: 'cm' | 'in';
262
+ /** ISO date (YYYY-MM-DD). */
263
+ birthDate?: string;
264
+ createdAt: number;
265
+ updatedAt?: number;
266
+ }
267
+
268
+ declare const DOMAIN_PROJECTION_VERSION = 1;
269
+ type DomainPredicate = {
270
+ field: 'id' | 'noteId' | 'type' | 'catalog' | 'source' | 'tags' | 'effort';
271
+ values: string[];
272
+ negate?: boolean;
273
+ } | {
274
+ field: 'text';
275
+ value: string;
276
+ } | {
277
+ field: 'date';
278
+ start: number;
279
+ end: number;
280
+ endExclusive?: boolean;
281
+ } | {
282
+ field: 'page';
283
+ value: boolean;
284
+ } | {
285
+ field: 'defaultNotes';
286
+ feeds: boolean;
287
+ } | {
288
+ field: 'sourceFence';
289
+ };
290
+ type DomainOrder = {
291
+ field: 'id' | 'createdAt' | 'date' | 'title' | 'noteTitle' | 'dataType' | 'position';
292
+ direction: 'asc' | 'desc';
293
+ };
294
+ type DomainQuery = {
295
+ plan: 'notes' | 'blocks' | 'entries';
296
+ selection?: DomainPredicate[];
297
+ filters?: DomainPredicate[];
298
+ order?: DomainOrder[];
299
+ offset?: number;
300
+ limit?: number;
301
+ };
302
+ interface DomainEntry {
303
+ note: Note;
304
+ segments: NoteSegment[];
305
+ tags: Tag[];
306
+ links: PageNote[];
307
+ pages: Page[];
308
+ }
309
+ type DomainQueryResult = {
310
+ projectionVersion: typeof DOMAIN_PROJECTION_VERSION;
311
+ selectedCount: number;
312
+ matchedCount: number;
313
+ } & ({
314
+ plan: 'notes';
315
+ rows: Note[];
316
+ } | {
317
+ plan: 'blocks';
318
+ rows: BlockIndexRow[];
319
+ } | {
320
+ plan: 'entries';
321
+ rows: DomainEntry[];
322
+ });
323
+ declare function parseDomainQueryResult(value: unknown, plan: DomainQuery['plan']): DomainQueryResult;
324
+
325
+ /**
326
+ * IStorage — backend-neutral storage contract (moved verbatim from
327
+ * apps/playground/src/services/storage/IStorage.ts). Implemented by
328
+ * IndexedDBStorage / InMemoryStorage in the app and ApiStorage here.
329
+ */
330
+
331
+ interface StorageSchema {
332
+ notes: Note;
333
+ page: Page;
334
+ page_notes: PageNote;
335
+ tags: Tag;
336
+ tag_types: TagTypeRecord;
337
+ note_tags: NoteTag;
338
+ segments: NoteSegment;
339
+ sessions: Session;
340
+ attachments: Attachment;
341
+ events: EventRecord;
342
+ field_catalog: FieldCatalogEntry;
343
+ field_sources: FieldSourceRecord;
344
+ field_values: FieldValueRecord;
345
+ field_catalog_meta: CatalogBackfillState;
346
+ efforts: IEffort;
347
+ block_index: BlockIndexRow;
348
+ block_efforts: BlockEffort;
349
+ meta: unknown;
350
+ memberships: Membership;
351
+ }
352
+ type StoreName = keyof StorageSchema;
353
+ type StoreType<K extends StoreName> = StorageSchema[K];
354
+ interface IReadOnlyStore<T> {
355
+ get(key: IDBValidKey): Promise<T | undefined>;
356
+ getAll(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<T[]>;
357
+ getAllFromIndex(indexName: string, query?: IDBValidKey | IDBKeyRange, count?: number): Promise<T[]>;
358
+ count(query?: IDBValidKey | IDBKeyRange): Promise<number>;
359
+ countFromIndex(indexName: string, query?: IDBValidKey | IDBKeyRange): Promise<number>;
360
+ }
361
+ interface IReadWriteStore<T> extends IReadOnlyStore<T> {
362
+ put(value: T, key?: IDBValidKey): Promise<IDBValidKey>;
363
+ delete(key: IDBValidKey | IDBKeyRange): Promise<void>;
364
+ clear(): Promise<void>;
365
+ }
366
+ interface IStorageTransaction {
367
+ readonly<K extends StoreName>(store: K): IReadOnlyStore<StoreType<K>>;
368
+ readwrite<K extends StoreName>(store: K): IReadWriteStore<StoreType<K>>;
369
+ }
370
+ interface IStorage {
371
+ queryDomain?(query: DomainQuery): Promise<DomainQueryResult | undefined>;
372
+ readonly<K extends StoreName>(store: K): IReadOnlyStore<StoreType<K>>;
373
+ readwrite<K extends StoreName>(store: K): IReadWriteStore<StoreType<K>>;
374
+ transaction<K extends StoreName, R>(stores: K[], mode: 'readonly' | 'readwrite', fn: (tx: IStorageTransaction) => Promise<R>): Promise<R>;
375
+ wipe(): Promise<void>;
376
+ close(): Promise<void>;
377
+ }
378
+
379
+ /**
380
+ * STORE_DEFS — runtime store/index spec for the wodwiki database, derived
381
+ * verbatim from IndexedDBStorage.open()'s upgrade callback (DB_VERSION 25,
382
+ * apps/playground/src/services/storage/IndexedDBStorage.ts). The API server
383
+ * builds its SQL schema from this spec.
384
+ *
385
+ * V25 adds the memberships store (system) and the by-user ownership index
386
+ * on every store that can hold user-created rows (see USER_OWNED_STORES in
387
+ * membership.ts — the ownership crosswalk).
388
+ *
389
+ * keyPath convention: array of property paths in key order. A single-element
390
+ * array is a simple (scalar) key; multiple elements form a compound key.
391
+ * Dotted paths ('baseAttributes.discipline') address nested properties.
392
+ */
393
+
394
+ /**
395
+ * Shared IndexedDB schema version — the browser database opens at exactly
396
+ * this version (apps/playground/src/services/storage/IndexedDBStorage.ts
397
+ * imports it), so store/index drift between the app and this spec is a type
398
+ * error, not a runtime surprise. Bump on every store/index change and add
399
+ * the matching guarded upgrade step.
400
+ *
401
+ * 27 supersedes 26: real databases already upgraded to the first 26 cut
402
+ * (before by-source / by-created / the unique note-tag pair) exist in the
403
+ * wild, so "version 26" is ambiguous — 27's presence-guarded repairs bring
404
+ * every mixed DB to the same shape.
405
+ */
406
+ declare const DB_VERSION = 28;
407
+ /**
408
+ * Typed field metadata — column type per extracted field path, shared with
409
+ * the API so its SQL DDL derives DOUBLE PRECISION / INTEGER / BOOLEAN /
410
+ * TEXT from this table instead of a hardcoded per-index allowlist.
411
+ *
412
+ * Extraction rule (API side): a primary-key column `k<i>` takes the type of
413
+ * STORE_DEFS store's keyPath[i]; an index column `i_<index>_<part>` takes the
414
+ * type of that index's keyPath[part] here. Anything absent defaults to TEXT.
415
+ * Civil-date strings stay TEXT on purpose (page.date is 'YYYY-MM-DD');
416
+ * instants are epoch-ms numbers (notes.date, createdAt, timestamp).
417
+ */
418
+ type StorageColumnType = 'number' | 'text' | 'boolean';
419
+ declare const COLUMN_TYPES: Record<StoreName, Record<string, StorageColumnType>>;
420
+ interface IndexDef {
421
+ name: string;
422
+ keyPath: string[];
423
+ unique?: boolean;
424
+ multiEntry?: boolean;
425
+ }
426
+ interface StoreDef {
427
+ name: StoreName;
428
+ keyPath: string[];
429
+ indexes: IndexDef[];
430
+ /** System stores survive wipe() (identity must not be reset with data). */
431
+ system?: boolean;
432
+ }
433
+ declare const STORE_DEFS: StoreDef[];
434
+
435
+ /**
436
+ * Wire DTOs + key helpers shared by the storage client (ApiStorage) and the
437
+ * API server. Keys are scalars or scalar tuples (keyPath order); ranges bound
438
+ * them. Over HTTP, keys/ranges travel as urlencoded JSON query params.
439
+ *
440
+ * Range-typed helpers accept real IDBKeyRange instances AND plain RangeDTO
441
+ * literals — non-IDB runtimes (Bun, node, tests) have no IDBKeyRange global,
442
+ * so everything duck-types on field shape.
443
+ */
444
+
445
+ type Scalar = string | number;
446
+ /** Primary key: scalar for single-property keyPaths, tuple for compound. */
447
+ type KeyDTO = Scalar | Scalar[];
448
+ interface RangeDTO {
449
+ only?: KeyDTO;
450
+ /** Inclusive lower bound unless lowerOpen. Scalar or tuple (keyPath order). */
451
+ lower?: KeyDTO;
452
+ /** Inclusive upper bound unless upperOpen. Scalar or tuple (keyPath order). */
453
+ upper?: KeyDTO;
454
+ lowerOpen?: boolean;
455
+ upperOpen?: boolean;
456
+ }
457
+ type TxOp = {
458
+ op: 'put';
459
+ store: StoreName;
460
+ value: unknown;
461
+ } | {
462
+ op: 'delete';
463
+ store: StoreName;
464
+ key: KeyDTO;
465
+ } | {
466
+ op: 'clear';
467
+ store: StoreName;
468
+ };
469
+ /** Anything shaped like a range: real IDBKeyRange or a plain RangeDTO literal. */
470
+ declare function isRange(arg: unknown): arg is RangeDTO;
471
+ /** Reads one dotted property path ('a.b.c') off a row. */
472
+ declare function extractKeyPart(value: unknown, dottedPath: string): Scalar | undefined;
473
+ /** Extracts a row's primary key per the store's inline keyPath. */
474
+ declare function keyFromValue(def: StoreDef, value: unknown): KeyDTO;
475
+ /** Canonical url-safe string form of a key (also the buffer/tx match key). */
476
+ declare function encodeKey(key: KeyDTO): string;
477
+ /**
478
+ * Converts an IndexedDB query — bare key, IDBKeyRange, or an already-wire
479
+ * RangeDTO — to its wire form. Duck-types: objects with lower/upper/
480
+ * lowerOpen/upperOpen fields (real ranges or plain literals) pass through;
481
+ * scalars and tuple arrays become {only}.
482
+ */
483
+ declare function rangeFromIDB(query?: IDBValidKey | IDBKeyRange | RangeDTO): RangeDTO | undefined;
484
+ /** IndexedDB key ordering: numbers < strings < tuples; tuples lexicographic. */
485
+ declare function compareKeys(a: KeyDTO, b: KeyDTO): number;
486
+ /** True when a key matches a wire range (IDB bound semantics). */
487
+ declare function keyInRange(key: KeyDTO, range: RangeDTO): boolean;
488
+
489
+ export { type Attachment as A, type BlockEffort as B, COLUMN_TYPES as C, type DomainQuery as D, type TxOp as E, compareKeys as F, encodeKey as G, extractKeyPart as H, type IStorage as I, isRange as J, type KeyDTO as K, keyFromValue as L, type Membership as M, type Note as N, keyInRange as O, type Page as P, parseDomainQueryResult as Q, type RangeDTO as R, type StoreName as S, type Tag as T, rangeFromIDB as U, type StoreDef as a, type IReadOnlyStore as b, type StoreType as c, type IReadWriteStore as d, type DomainQueryResult as e, type IStorageTransaction as f, type BlockIndexRow as g, DB_VERSION as h, DOMAIN_PROJECTION_VERSION as i, type DomainEntry as j, type DomainOrder as k, type DomainPredicate as l, type IndexDef as m, type NoteKind as n, type NoteSegment as o, type NoteTag as p, type PageNote as q, type ResultOrigin as r, STORE_DEFS as s, type Scalar as t, type SegmentDataType as u, type Session as v, type StorageColumnType as w, type StorageSchema as x, type TagType as y, type TagTypeRecord as z };