@medicine-wheel/storage-provider 0.5.9 → 0.6.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Capture Record semantics shared by every provider.
3
+ *
4
+ * The registry holds records and URIs — where a take lives — never the bytes
5
+ * themselves, which stay behind the capture service that made them. Capture is
6
+ * owned by @miadi/capture and the gmtermux edge; here we keep only the id
7
+ * convention, filter predicate, ordering, and upsert-merge rule so the jsonl
8
+ * and neon backends agree on what a capture means.
9
+ *
10
+ * Vocabulary law: capture-vocabulary.spec.md (§8, the registry noun ruling).
11
+ */
12
+ import type { CaptureFilters, CaptureRecord } from './interface.js';
13
+ /**
14
+ * Compose the upsert key for a capture, mirroring @miadi/inquiry-weave's
15
+ * weaveRecordId composition: `capture:<episode_path>:<filename>` when the
16
+ * capture is episode-bound, `capture:<filename>` otherwise.
17
+ *
18
+ * This convention applies only when no id is sent — it is a fallback for
19
+ * id-less registration, not the identity rule. The id assigned at first
20
+ * registration is permanent (capture-vocabulary.spec.md §6).
21
+ */
22
+ export declare function captureRecordId(filename: string, episodePath?: string): string;
23
+ /** True when a capture record satisfies every provided filter. */
24
+ export declare function matchesCaptureFilters(record: CaptureRecord, filters: CaptureFilters): boolean;
25
+ /**
26
+ * Filter a batch of capture rows, dropping anything that did not survive the
27
+ * read (a Postgres payload that fails to parse arrives here as null), then
28
+ * order newest registration first.
29
+ */
30
+ export declare function filterAndOrderCaptures(records: readonly (CaptureRecord | null | undefined)[], filters?: CaptureFilters): CaptureRecord[];
31
+ /**
32
+ * Upsert merge: the incoming record wins wherever it speaks, the existing
33
+ * record fills wherever it stays silent. Provenance is observed, not demanded
34
+ * — so a later registration that learned less (no device, no sha256) must not
35
+ * erase what an earlier one knew. The first registration timestamp survives,
36
+ * matching the plan-perspective merge contract.
37
+ */
38
+ export declare function mergeCaptureRecords(existing: CaptureRecord, incoming: CaptureRecord): CaptureRecord;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Capture Record semantics shared by every provider.
3
+ *
4
+ * The registry holds records and URIs — where a take lives — never the bytes
5
+ * themselves, which stay behind the capture service that made them. Capture is
6
+ * owned by @miadi/capture and the gmtermux edge; here we keep only the id
7
+ * convention, filter predicate, ordering, and upsert-merge rule so the jsonl
8
+ * and neon backends agree on what a capture means.
9
+ *
10
+ * Vocabulary law: capture-vocabulary.spec.md (§8, the registry noun ruling).
11
+ */
12
+ /**
13
+ * Compose the upsert key for a capture, mirroring @miadi/inquiry-weave's
14
+ * weaveRecordId composition: `capture:<episode_path>:<filename>` when the
15
+ * capture is episode-bound, `capture:<filename>` otherwise.
16
+ *
17
+ * This convention applies only when no id is sent — it is a fallback for
18
+ * id-less registration, not the identity rule. The id assigned at first
19
+ * registration is permanent (capture-vocabulary.spec.md §6).
20
+ */
21
+ export function captureRecordId(filename, episodePath) {
22
+ return episodePath ? `capture:${episodePath}:${filename}` : `capture:${filename}`;
23
+ }
24
+ /** True when a capture record satisfies every provided filter. */
25
+ export function matchesCaptureFilters(record, filters) {
26
+ if (filters.episode_path !== undefined && record.episode_path !== filters.episode_path) {
27
+ return false;
28
+ }
29
+ if (filters.episode_number !== undefined && record.episode_number !== filters.episode_number) {
30
+ return false;
31
+ }
32
+ if (filters.composition !== undefined && record.composition !== filters.composition) {
33
+ return false;
34
+ }
35
+ if (filters.kind !== undefined && record.kind !== filters.kind) {
36
+ return false;
37
+ }
38
+ if (filters.origin !== undefined && record.origin !== filters.origin) {
39
+ return false;
40
+ }
41
+ if (filters.device !== undefined && record.device !== filters.device) {
42
+ return false;
43
+ }
44
+ if (filters.filename !== undefined && record.filename !== filters.filename) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ /**
50
+ * Filter a batch of capture rows, dropping anything that did not survive the
51
+ * read (a Postgres payload that fails to parse arrives here as null), then
52
+ * order newest registration first.
53
+ */
54
+ export function filterAndOrderCaptures(records, filters = {}) {
55
+ return records
56
+ .filter((record) => record !== null &&
57
+ record !== undefined &&
58
+ typeof record === 'object' &&
59
+ matchesCaptureFilters(record, filters))
60
+ .sort((left, right) => Date.parse(right.registered_at) - Date.parse(left.registered_at));
61
+ }
62
+ /**
63
+ * Upsert merge: the incoming record wins wherever it speaks, the existing
64
+ * record fills wherever it stays silent. Provenance is observed, not demanded
65
+ * — so a later registration that learned less (no device, no sha256) must not
66
+ * erase what an earlier one knew. The first registration timestamp survives,
67
+ * matching the plan-perspective merge contract.
68
+ */
69
+ export function mergeCaptureRecords(existing, incoming) {
70
+ const merged = { ...existing };
71
+ for (const [key, value] of Object.entries(incoming)) {
72
+ if (value !== undefined)
73
+ merged[key] = value;
74
+ }
75
+ merged.registered_at = existing.registered_at;
76
+ return merged;
77
+ }
package/dist/index.d.ts CHANGED
@@ -15,12 +15,14 @@
15
15
  * const node = await store.getNode('elder-1');
16
16
  * ```
17
17
  */
18
- export type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, WeaveSyncState, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveEpisode, PlanPerspectiveFilters, CeremonialPhase, DiaryEntryType, DiaryEntryLocation, DiaryEntryMetadata, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventKind, CeremonyEventParticipant, CeremonyEventRecord, CeremonyEventFilters, ProviderType, NodePatch, EdgePatch, } from './interface.js';
18
+ export type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, WeaveSyncState, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveEpisode, PlanPerspectiveFilters, CeremonialPhase, DiaryEntryType, DiaryEntryLocation, DiaryEntryMetadata, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventKind, CeremonyEventParticipant, CeremonyEventRecord, CeremonyEventFilters, CaptureKind, CaptureOrigin, CaptureRecord, CaptureFilters, ProviderType, NodePatch, EdgePatch, } from './interface.js';
19
+ export { CAPTURE_KINDS, CAPTURE_ORIGINS } from './interface.js';
19
20
  export { NodeNotFoundError, EdgeNotFoundError, NodeHasRelationsError, } from './interface.js';
20
21
  export { mergePlanPerspectiveRecords, unionPlanPerspectiveEpisodes, matchesPlanPerspectiveFilters, } from './plan-perspectives.js';
21
22
  export { matchesInquiryWeaveFilters, filterInquiryWeaves, } from './inquiry-weaves.js';
22
23
  export { matchesDiaryEntryFilters, filterAndOrderDiaryEntries, } from './diary-records.js';
23
24
  export { matchesCeremonyEventFilters } from './ceremony-events.js';
25
+ export { captureRecordId, matchesCaptureFilters, filterAndOrderCaptures, mergeCaptureRecords, } from './capture-records.js';
24
26
  export { createProvider, detectProvider } from './factory.js';
25
27
  export { JsonlProvider } from './jsonl.js';
26
28
  export { NeonProvider } from './neon.js';
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * const node = await store.getNode('elder-1');
16
16
  * ```
17
17
  */
18
+ // Capture vocabulary as values — checkable from plain JavaScript
19
+ export { CAPTURE_KINDS, CAPTURE_ORIGINS } from './interface.js';
18
20
  // Typed errors (thrown by update/delete operations)
19
21
  export { NodeNotFoundError, EdgeNotFoundError, NodeHasRelationsError, } from './interface.js';
20
22
  // Plan Perspective merge/filter semantics (shared across providers)
@@ -25,6 +27,8 @@ export { matchesInquiryWeaveFilters, filterInquiryWeaves, } from './inquiry-weav
25
27
  export { matchesDiaryEntryFilters, filterAndOrderDiaryEntries, } from './diary-records.js';
26
28
  // Ceremony Event filter semantics (shared across providers)
27
29
  export { matchesCeremonyEventFilters } from './ceremony-events.js';
30
+ // Capture id/filter/merge semantics (shared across providers)
31
+ export { captureRecordId, matchesCaptureFilters, filterAndOrderCaptures, mergeCaptureRecords, } from './capture-records.js';
28
32
  // Factory
29
33
  export { createProvider, detectProvider } from './factory.js';
30
34
  // Providers (for direct instantiation)
@@ -218,6 +218,70 @@ export interface CeremonyEventFilters {
218
218
  direction?: DirectionName;
219
219
  repository?: string;
220
220
  }
221
+ /**
222
+ * Kinds are published as a value, not only a type — a plain-JavaScript edge
223
+ * device cannot import a TypeScript union, so the array is the checkable
224
+ * contract and the type derives FROM it.
225
+ */
226
+ export declare const CAPTURE_KINDS: readonly ["audio", "video", "midi", "other"];
227
+ export type CaptureKind = (typeof CAPTURE_KINDS)[number];
228
+ /**
229
+ * How a capture came to exist — the axis that says what must never be lost.
230
+ * `captured` is unrepeatable: a device, an instant, one chance. `derived` is
231
+ * regenerable from a source still held. `authored` was written rather than
232
+ * captured or generated. Flattening these loses the only property that
233
+ * distinguishes an irreplaceable take from a file rebuildable on demand.
234
+ */
235
+ export declare const CAPTURE_ORIGINS: readonly ["captured", "derived", "authored"];
236
+ export type CaptureOrigin = (typeof CAPTURE_ORIGINS)[number];
237
+ export interface CaptureRecord extends Record<string, unknown> {
238
+ /**
239
+ * Stable upsert key. Convention: `capture:<episode_path>:<filename>` when
240
+ * episode-bound, `capture:<filename>` otherwise — see captureRecordId().
241
+ */
242
+ id: string;
243
+ /** The file's own name, as the capture service or author named it. */
244
+ filename: string;
245
+ kind: CaptureKind;
246
+ origin: CaptureOrigin;
247
+ /**
248
+ * Where the bytes live — a URI or path the capture service answers for.
249
+ * The registry stores this pointer and nothing behind it.
250
+ */
251
+ uri: string;
252
+ /** The device that made the capture, as it named itself. */
253
+ device?: string;
254
+ /** ISO 8601 instant the capture began. */
255
+ started_at?: string;
256
+ /** ISO 8601 instant the capture finished. */
257
+ stopped_at?: string;
258
+ /** Duration of the finished take, in seconds. */
259
+ duration_seconds?: number;
260
+ /** Size of the finished file in bytes — a count, never the bytes themselves. */
261
+ bytes?: number;
262
+ sha256?: string;
263
+ mimetype?: string;
264
+ /** Episode directory path relative to the chronicle root. */
265
+ episode_path?: string;
266
+ episode_number?: number;
267
+ /** Composition slug this capture belongs to, when known. */
268
+ composition?: string;
269
+ /** For derived captures: the filename or record id this one was made from. */
270
+ source_artifact?: string;
271
+ /** ISO 8601 registration timestamp; the first registration's stamp survives upserts. */
272
+ registered_at: string;
273
+ /** Free-form origin-of-registration, e.g. '@miadi/capture' or 'gmtermux'. */
274
+ source?: string;
275
+ }
276
+ export interface CaptureFilters {
277
+ episode_path?: string;
278
+ episode_number?: number;
279
+ composition?: string;
280
+ kind?: CaptureKind;
281
+ origin?: CaptureOrigin;
282
+ device?: string;
283
+ filename?: string;
284
+ }
221
285
  export interface StorageProvider {
222
286
  readonly name: string;
223
287
  connect(): Promise<void>;
@@ -259,5 +323,8 @@ export interface StorageProvider {
259
323
  registerCeremonyEvent(record: CeremonyEventRecord): Promise<CeremonyEventRecord>;
260
324
  getCeremonyEvent(id: string): Promise<CeremonyEventRecord | null>;
261
325
  listCeremonyEvents(filters?: CeremonyEventFilters): Promise<CeremonyEventRecord[]>;
326
+ registerCapture(record: CaptureRecord): Promise<CaptureRecord>;
327
+ getCapture(id: string): Promise<CaptureRecord | null>;
328
+ listCaptures(filters?: CaptureFilters): Promise<CaptureRecord[]>;
262
329
  }
263
330
  export type ProviderType = 'jsonl' | 'neon' | 'redis' | 'auto';
package/dist/interface.js CHANGED
@@ -41,3 +41,23 @@ export class NodeHasRelationsError extends Error {
41
41
  this.name = 'NodeHasRelationsError';
42
42
  }
43
43
  }
44
+ // ── Capture Records ──
45
+ // A registry of captures: records and URIs only, never bytes. The bytes stay
46
+ // behind the capture service that made them (@miadi/capture and the gmtermux
47
+ // edge); this registry makes captures queryable for chronicle surfaces.
48
+ // Vocabulary aligns with @miadi/episodic-memory-schema's artifact axes and is
49
+ // ruled by capture-vocabulary.spec.md (§8, the registry noun ruling).
50
+ /**
51
+ * Kinds are published as a value, not only a type — a plain-JavaScript edge
52
+ * device cannot import a TypeScript union, so the array is the checkable
53
+ * contract and the type derives FROM it.
54
+ */
55
+ export const CAPTURE_KINDS = ['audio', 'video', 'midi', 'other'];
56
+ /**
57
+ * How a capture came to exist — the axis that says what must never be lost.
58
+ * `captured` is unrepeatable: a device, an instant, one chance. `derived` is
59
+ * regenerable from a source still held. `authored` was written rather than
60
+ * captured or generated. Flattening these loses the only property that
61
+ * distinguishes an irreplaceable take from a file rebuildable on demand.
62
+ */
63
+ export const CAPTURE_ORIGINS = ['captured', 'derived', 'authored'];
package/dist/jsonl.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
2
- import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventRecord, CeremonyEventFilters, NodePatch, EdgePatch } from './interface.js';
2
+ import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventRecord, CeremonyEventFilters, CaptureRecord, CaptureFilters, NodePatch, EdgePatch } from './interface.js';
3
3
  export declare class JsonlProvider implements StorageProvider {
4
4
  readonly name = "jsonl";
5
5
  readonly dataDir: string;
@@ -10,6 +10,7 @@ export declare class JsonlProvider implements StorageProvider {
10
10
  private readonly planPerspectivesFile;
11
11
  private readonly diaryEntriesFile;
12
12
  private readonly ceremonyEventsFile;
13
+ private readonly capturesFile;
13
14
  constructor(dataDir?: string);
14
15
  connect(): Promise<void>;
15
16
  disconnect(): Promise<void>;
@@ -46,6 +47,9 @@ export declare class JsonlProvider implements StorageProvider {
46
47
  registerCeremonyEvent(record: CeremonyEventRecord): Promise<CeremonyEventRecord>;
47
48
  getCeremonyEvent(id: string): Promise<CeremonyEventRecord | null>;
48
49
  listCeremonyEvents(filters?: CeremonyEventFilters): Promise<CeremonyEventRecord[]>;
50
+ registerCapture(record: CaptureRecord): Promise<CaptureRecord>;
51
+ getCapture(id: string): Promise<CaptureRecord | null>;
52
+ listCaptures(filters?: CaptureFilters): Promise<CaptureRecord[]>;
49
53
  private readNodes;
50
54
  private readEdges;
51
55
  private readCeremonies;
@@ -53,6 +57,7 @@ export declare class JsonlProvider implements StorageProvider {
53
57
  private readPlanPerspectives;
54
58
  private readDiaryEntries;
55
59
  private readCeremonyEvents;
60
+ private readCaptures;
56
61
  private upsertById;
57
62
  private upsertEdge;
58
63
  private parseNode;
package/dist/jsonl.js CHANGED
@@ -6,6 +6,7 @@ import { mergePlanPerspectiveRecords, matchesPlanPerspectiveFilters, } from './p
6
6
  import { filterInquiryWeaves } from './inquiry-weaves.js';
7
7
  import { filterAndOrderDiaryEntries } from './diary-records.js';
8
8
  import { matchesCeremonyEventFilters } from './ceremony-events.js';
9
+ import { mergeCaptureRecords, filterAndOrderCaptures, } from './capture-records.js';
9
10
  export class JsonlProvider {
10
11
  name = 'jsonl';
11
12
  dataDir;
@@ -16,6 +17,7 @@ export class JsonlProvider {
16
17
  planPerspectivesFile;
17
18
  diaryEntriesFile;
18
19
  ceremonyEventsFile;
20
+ capturesFile;
19
21
  constructor(dataDir) {
20
22
  this.dataDir = path.resolve(dataDir ?? resolveProjectDataDir());
21
23
  this.nodesFile = path.join(this.dataDir, 'nodes.jsonl');
@@ -25,6 +27,7 @@ export class JsonlProvider {
25
27
  this.planPerspectivesFile = path.join(this.dataDir, 'plan-perspectives.jsonl');
26
28
  this.diaryEntriesFile = path.join(this.dataDir, 'diary-entries.jsonl');
27
29
  this.ceremonyEventsFile = path.join(this.dataDir, 'ceremony-events.jsonl');
30
+ this.capturesFile = path.join(this.dataDir, 'captures.jsonl');
28
31
  }
29
32
  async connect() {
30
33
  ensureDirectory(this.dataDir);
@@ -254,6 +257,24 @@ export class JsonlProvider {
254
257
  .filter((record) => matchesCeremonyEventFilters(record, filters))
255
258
  .sort(sortByNewest('timestamp'));
256
259
  }
260
+ async registerCapture(record) {
261
+ let merged = record;
262
+ await withWriteLock(this.capturesFile, () => {
263
+ const records = readJsonl(this.capturesFile);
264
+ const existing = records.find((candidate) => candidate.id === record.id);
265
+ merged = existing ? mergeCaptureRecords(existing, record) : record;
266
+ const nextRecords = records.filter((candidate) => candidate.id !== record.id);
267
+ nextRecords.push(merged);
268
+ writeJsonl(this.capturesFile, nextRecords);
269
+ });
270
+ return merged;
271
+ }
272
+ async getCapture(id) {
273
+ return this.readCaptures().find((record) => record.id === id) ?? null;
274
+ }
275
+ async listCaptures(filters = {}) {
276
+ return filterAndOrderCaptures(this.readCaptures(), filters);
277
+ }
257
278
  readNodes() {
258
279
  return readJsonl(this.nodesFile);
259
280
  }
@@ -275,6 +296,9 @@ export class JsonlProvider {
275
296
  readCeremonyEvents() {
276
297
  return readJsonl(this.ceremonyEventsFile);
277
298
  }
299
+ readCaptures() {
300
+ return readJsonl(this.capturesFile);
301
+ }
278
302
  async upsertById(filePath, item) {
279
303
  await withWriteLock(filePath, () => {
280
304
  const nextRecords = readJsonl(filePath).filter((candidate) => candidate.id !== item.id);
package/dist/neon.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Neon (Postgres) Storage Provider
3
3
  */
4
4
  import type { DirectionName, NodeType, CeremonyType } from '@medicine-wheel/ontology-core';
5
- import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventRecord, CeremonyEventFilters, NodePatch, EdgePatch } from './interface.js';
5
+ import type { StorageProvider, RelationalNode, RelationalEdge, CeremonyLog, WeaveRecord, InquiryWeaveFilters, PlanPerspectiveRecord, PlanPerspectiveFilters, DiaryEntryRecord, DiaryEntryFilters, CeremonyEventRecord, CeremonyEventFilters, CaptureRecord, CaptureFilters, NodePatch, EdgePatch } from './interface.js';
6
6
  export declare class NeonProvider implements StorageProvider {
7
7
  readonly name = "neon";
8
8
  private sql;
@@ -50,4 +50,8 @@ export declare class NeonProvider implements StorageProvider {
50
50
  getCeremonyEvent(id: string): Promise<CeremonyEventRecord | null>;
51
51
  listCeremonyEvents(filters?: CeremonyEventFilters): Promise<CeremonyEventRecord[]>;
52
52
  private ensureCeremonyEventsTable;
53
+ registerCapture(record: CaptureRecord): Promise<CaptureRecord>;
54
+ getCapture(id: string): Promise<CaptureRecord | null>;
55
+ listCaptures(filters?: CaptureFilters): Promise<CaptureRecord[]>;
56
+ private ensureCapturesTable;
53
57
  }
package/dist/neon.js CHANGED
@@ -6,6 +6,7 @@ import { mergePlanPerspectiveRecords, matchesPlanPerspectiveFilters, } from './p
6
6
  import { filterInquiryWeaves } from './inquiry-weaves.js';
7
7
  import { filterAndOrderDiaryEntries } from './diary-records.js';
8
8
  import { matchesCeremonyEventFilters } from './ceremony-events.js';
9
+ import { mergeCaptureRecords, filterAndOrderCaptures, } from './capture-records.js';
9
10
  export class NeonProvider {
10
11
  name = 'neon';
11
12
  sql = null;
@@ -19,6 +20,7 @@ export class NeonProvider {
19
20
  await this.ensurePlanPerspectivesTable();
20
21
  await this.ensureDiaryEntriesTable();
21
22
  await this.ensureCeremonyEventsTable();
23
+ await this.ensureCapturesTable();
22
24
  }
23
25
  async disconnect() {
24
26
  this.sql = null;
@@ -489,6 +491,70 @@ export class NeonProvider {
489
491
  await this.db `CREATE INDEX IF NOT EXISTS idx_ceremony_events_phase ON ceremony_events(phase)`;
490
492
  await this.db `CREATE INDEX IF NOT EXISTS idx_ceremony_events_repository ON ceremony_events(repository)`;
491
493
  }
494
+ // ── Capture Operations (records + URIs, never bytes) ──
495
+ async registerCapture(record) {
496
+ const existing = await this.getCapture(record.id);
497
+ const merged = existing ? mergeCaptureRecords(existing, record) : record;
498
+ await this.db `
499
+ INSERT INTO captures (id, payload, filename, kind, origin, episode_path, episode_number, composition, device, registered_at, updated_at)
500
+ VALUES (
501
+ ${merged.id},
502
+ ${JSON.stringify(merged)},
503
+ ${merged.filename},
504
+ ${merged.kind},
505
+ ${merged.origin},
506
+ ${merged.episode_path ?? null},
507
+ ${merged.episode_number ?? null},
508
+ ${merged.composition ?? null},
509
+ ${merged.device ?? null},
510
+ ${merged.registered_at},
511
+ NOW()
512
+ )
513
+ ON CONFLICT (id) DO UPDATE SET
514
+ payload = EXCLUDED.payload,
515
+ filename = EXCLUDED.filename,
516
+ kind = EXCLUDED.kind,
517
+ origin = EXCLUDED.origin,
518
+ episode_path = EXCLUDED.episode_path,
519
+ episode_number = EXCLUDED.episode_number,
520
+ composition = EXCLUDED.composition,
521
+ device = EXCLUDED.device,
522
+ registered_at = EXCLUDED.registered_at,
523
+ updated_at = NOW()
524
+ `;
525
+ return merged;
526
+ }
527
+ async getCapture(id) {
528
+ const rows = await this.db `SELECT payload FROM captures WHERE id = ${id}`;
529
+ if (rows.length === 0)
530
+ return null;
531
+ return parseJsonValue(rows[0].payload, null);
532
+ }
533
+ async listCaptures(filters = {}) {
534
+ const rows = await this.db `SELECT payload FROM captures ORDER BY updated_at DESC`;
535
+ return filterAndOrderCaptures(rows.map((row) => parseJsonValue(row.payload, null)), filters);
536
+ }
537
+ async ensureCapturesTable() {
538
+ await this.db `
539
+ CREATE TABLE IF NOT EXISTS captures (
540
+ id TEXT PRIMARY KEY,
541
+ payload JSONB NOT NULL,
542
+ filename TEXT NOT NULL,
543
+ kind TEXT NOT NULL,
544
+ origin TEXT NOT NULL,
545
+ episode_path TEXT,
546
+ episode_number INTEGER,
547
+ composition TEXT,
548
+ device TEXT,
549
+ registered_at TIMESTAMPTZ,
550
+ updated_at TIMESTAMPTZ DEFAULT NOW()
551
+ )
552
+ `;
553
+ await this.db `CREATE INDEX IF NOT EXISTS idx_captures_episode_path ON captures(episode_path)`;
554
+ await this.db `CREATE INDEX IF NOT EXISTS idx_captures_episode_number ON captures(episode_number)`;
555
+ await this.db `CREATE INDEX IF NOT EXISTS idx_captures_composition ON captures(composition)`;
556
+ await this.db `CREATE INDEX IF NOT EXISTS idx_captures_kind ON captures(kind)`;
557
+ }
492
558
  }
493
559
  function getConnectionString() {
494
560
  return process.env.DATABASE_URL ?? process.env.POSTGRES_URL ?? process.env.NEON_DATABASE_URL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/storage-provider",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "@neondatabase/serverless": "^0.10.0",
18
18
  "@upstash/redis": "^1.34.3",
19
- "@medicine-wheel/ontology-core": "^0.5.9"
19
+ "@medicine-wheel/ontology-core": "^0.6.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "typescript": "^5.7.0"