catence 0.0.0 → 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 +661 -0
- package/README.md +373 -29
- package/config.example.json +63 -0
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/release.d.ts +20 -0
- package/dist/contracts/release.js +17 -0
- package/dist/contracts/runtime.d.ts +11 -0
- package/dist/contracts/runtime.js +2 -0
- package/dist/contracts/staging.d.ts +183 -0
- package/dist/contracts/staging.js +36 -0
- package/dist/contracts/storage.d.ts +15 -0
- package/dist/contracts/storage.js +2 -0
- package/dist/core/dashboard/snapshot.d.ts +16 -0
- package/dist/core/dashboard/snapshot.js +65 -0
- package/dist/core/query/activity-discovery.d.ts +21 -0
- package/dist/core/query/activity-discovery.js +133 -0
- package/dist/core/query/analytics.d.ts +59 -0
- package/dist/core/query/analytics.js +395 -0
- package/dist/core/query/catalog.d.ts +26 -0
- package/dist/core/query/catalog.js +141 -0
- package/dist/core/query/fitness.d.ts +40 -0
- package/dist/core/query/fitness.js +297 -0
- package/dist/core/query/repository.d.ts +19 -0
- package/dist/core/query/repository.js +182 -0
- package/dist/core/query/sql-guard.d.ts +7 -0
- package/dist/core/query/sql-guard.js +121 -0
- package/dist/core/query/sql.d.ts +2 -0
- package/dist/core/query/sql.js +5 -0
- package/dist/core/query/swimming.d.ts +19 -0
- package/dist/core/query/swimming.js +184 -0
- package/dist/core/query/wellness.d.ts +45 -0
- package/dist/core/query/wellness.js +292 -0
- package/dist/core/retrieval/index.d.ts +13 -0
- package/dist/core/retrieval/index.js +248 -0
- package/dist/core/runtime/configuration.d.ts +71 -0
- package/dist/core/runtime/configuration.js +106 -0
- package/dist/core/runtime/limiter.d.ts +13 -0
- package/dist/core/runtime/limiter.js +17 -0
- package/dist/elt/application/demo.d.ts +23 -0
- package/dist/elt/application/demo.js +165 -0
- package/dist/elt/application/management.d.ts +25 -0
- package/dist/elt/application/management.js +283 -0
- package/dist/elt/ingestion/importer.d.ts +8 -0
- package/dist/elt/ingestion/importer.js +87 -0
- package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
- package/dist/elt/ingestion/providers/intervals.js +224 -0
- package/dist/elt/ingestion/providers/registry.d.ts +19 -0
- package/dist/elt/ingestion/providers/registry.js +62 -0
- package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
- package/dist/elt/ingestion/providers/strava/service.js +365 -0
- package/dist/elt/normalization/activities/linking.d.ts +5 -0
- package/dist/elt/normalization/activities/linking.js +165 -0
- package/dist/elt/normalization/normalizers.d.ts +5 -0
- package/dist/elt/normalization/normalizers.js +816 -0
- package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
- package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
- package/dist/elt/storage/artifacts.d.ts +13 -0
- package/dist/elt/storage/artifacts.js +48 -0
- package/dist/elt/storage/database.d.ts +74 -0
- package/dist/elt/storage/database.js +296 -0
- package/dist/elt/storage/migrations.d.ts +3 -0
- package/dist/elt/storage/migrations.js +832 -0
- package/dist/elt/storage/migrations.legacy.d.ts +12 -0
- package/dist/elt/storage/migrations.legacy.js +332 -0
- package/dist/elt/storage/sql.d.ts +2 -0
- package/dist/elt/storage/sql.js +7 -0
- package/dist/elt/storage/write-lock.d.ts +6 -0
- package/dist/elt/storage/write-lock.js +28 -0
- package/dist/elt/streams.d.ts +24 -0
- package/dist/elt/streams.js +62 -0
- package/dist/interfaces/cli/commands.d.ts +2 -0
- package/dist/interfaces/cli/commands.js +101 -0
- package/dist/interfaces/cli/main.d.ts +2 -0
- package/dist/interfaces/cli/main.js +3 -0
- package/dist/interfaces/http/cli-options.d.ts +9 -0
- package/dist/interfaces/http/cli-options.js +81 -0
- package/dist/interfaces/http/server.d.ts +14 -0
- package/dist/interfaces/http/server.js +169 -0
- package/dist/interfaces/mcp/cli-options.d.ts +6 -0
- package/dist/interfaces/mcp/cli-options.js +36 -0
- package/dist/interfaces/mcp/main.d.ts +2 -0
- package/dist/interfaces/mcp/main.js +59 -0
- package/dist/interfaces/mcp/server.d.ts +8 -0
- package/dist/interfaces/mcp/server.js +521 -0
- package/dist/runtime/index.d.ts +29 -0
- package/dist/runtime/index.js +19 -0
- package/docs/architecture.md +100 -0
- package/docs/assets/image.png +0 -0
- package/docs/mcp-activity-retrieval.md +377 -0
- package/package.json +81 -6
- package/pyproject.toml +28 -0
- package/python/__init__.py +1 -0
- package/python/catence/__init__.py +0 -0
- package/python/catence/providers/__init__.py +0 -0
- package/python/catence/providers/garmin/__init__.py +3 -0
- package/python/catence/providers/garmin/cli.py +62 -0
- package/python/catence/providers/garmin/contracts.py +23 -0
- package/python/catence/providers/garmin/registry.py +48 -0
- package/python/catence/providers/garmin/staging.py +87 -0
- package/python/catence/providers/garmin/streams.py +195 -0
- package/python/catence/providers/garmin/worker.py +450 -0
- package/python/catence/providers/strava/__init__.py +3 -0
- package/python/catence/providers/strava/cli.py +404 -0
- package/python/catence/providers/strava/staging.py +73 -0
- package/uv.lock +708 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { json } from '../../storage/sql.js';
|
|
2
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
3
|
+
const text = (value) => typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : null;
|
|
4
|
+
const number = (value) => typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)) ? Number(value) : null;
|
|
5
|
+
const dateOnly = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : null;
|
|
6
|
+
async function storeGenericEntity(database, entity, payload) {
|
|
7
|
+
await database.run(`INSERT INTO source_entities (provider, entity_type, remote_id, parent_remote_id, occurred_on, source_updated_at, raw_object_hash, payload_json, extension_json)
|
|
8
|
+
VALUES ('strava', $entityType, $remoteId, $parentRemoteId, $occurredOn, $sourceUpdatedAt, $rawHash, $payload, $extension)
|
|
9
|
+
ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on, source_updated_at = excluded.source_updated_at, raw_object_hash = excluded.raw_object_hash, payload_json = excluded.payload_json, extension_json = excluded.extension_json, normalized_at = now()`, { entityType: entity.entityType, remoteId: entity.remoteId, parentRemoteId: entity.parentRemoteId, occurredOn: entity.occurredOn, sourceUpdatedAt: entity.sourceUpdatedAt, rawHash: entity.rawObjectHash, payload: json(payload), extension: json(entity.extension) });
|
|
10
|
+
await database.run(`INSERT INTO domain_entities (provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash)
|
|
11
|
+
VALUES ('strava', $entityType, $remoteId, $parentRemoteId, $occurredOn, $payload, $extension, $rawHash)
|
|
12
|
+
ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET parent_remote_id = excluded.parent_remote_id, occurred_on = excluded.occurred_on, payload_json = excluded.payload_json, extension_json = excluded.extension_json, raw_object_hash = excluded.raw_object_hash`, { entityType: entity.entityType, remoteId: entity.remoteId, parentRemoteId: entity.parentRemoteId, occurredOn: entity.occurredOn, rawHash: entity.rawObjectHash, payload: json(payload), extension: json(entity.extension) });
|
|
13
|
+
}
|
|
14
|
+
/** Persist richer segment facts without letting a sparse embedded history effort erase them. */
|
|
15
|
+
async function upsertSegment(database, source, rawHash) {
|
|
16
|
+
const segmentId = text(source.id);
|
|
17
|
+
if (!segmentId)
|
|
18
|
+
return;
|
|
19
|
+
const hasDescriptiveFacts = text(source.name) !== null || typeof source.starred === 'boolean' || number(source.average_grade) !== null || number(source.distance) !== null;
|
|
20
|
+
await database.run(`INSERT INTO strava_segments (segment_id, name, activity_type, distance_m, average_grade_pct, maximum_grade_pct, climb_category, elevation_high_m, elevation_low_m, total_elevation_gain_m, private, hazardous, starred, raw_object_hash, payload_json)
|
|
21
|
+
VALUES ($segmentId, $name, $activityType, $distanceM, $averageGrade, $maximumGrade, $climbCategory, $elevationHigh, $elevationLow, $elevationGain, $private, $hazardous, $starred, $rawHash, $payload)
|
|
22
|
+
ON CONFLICT (segment_id) DO UPDATE SET
|
|
23
|
+
name = COALESCE(excluded.name, strava_segments.name),
|
|
24
|
+
activity_type = COALESCE(excluded.activity_type, strava_segments.activity_type),
|
|
25
|
+
distance_m = COALESCE(excluded.distance_m, strava_segments.distance_m),
|
|
26
|
+
average_grade_pct = COALESCE(excluded.average_grade_pct, strava_segments.average_grade_pct),
|
|
27
|
+
maximum_grade_pct = COALESCE(excluded.maximum_grade_pct, strava_segments.maximum_grade_pct),
|
|
28
|
+
climb_category = COALESCE(excluded.climb_category, strava_segments.climb_category),
|
|
29
|
+
elevation_high_m = COALESCE(excluded.elevation_high_m, strava_segments.elevation_high_m),
|
|
30
|
+
elevation_low_m = COALESCE(excluded.elevation_low_m, strava_segments.elevation_low_m),
|
|
31
|
+
total_elevation_gain_m = COALESCE(excluded.total_elevation_gain_m, strava_segments.total_elevation_gain_m),
|
|
32
|
+
private = COALESCE(excluded.private, strava_segments.private),
|
|
33
|
+
hazardous = COALESCE(excluded.hazardous, strava_segments.hazardous),
|
|
34
|
+
starred = COALESCE(excluded.starred, strava_segments.starred),
|
|
35
|
+
raw_object_hash = CASE WHEN $hasDescriptiveFacts THEN excluded.raw_object_hash ELSE strava_segments.raw_object_hash END,
|
|
36
|
+
payload_json = CASE WHEN $hasDescriptiveFacts THEN excluded.payload_json ELSE strava_segments.payload_json END,
|
|
37
|
+
updated_at = now()`, {
|
|
38
|
+
segmentId, name: text(source.name), activityType: text(source.activity_type), distanceM: number(source.distance), averageGrade: number(source.average_grade), maximumGrade: number(source.maximum_grade), climbCategory: number(source.climb_category), elevationHigh: number(source.elevation_high), elevationLow: number(source.elevation_low), elevationGain: number(source.total_elevation_gain), private: typeof source.private === 'boolean' ? source.private : null, hazardous: typeof source.hazardous === 'boolean' ? source.hazardous : null, starred: typeof source.starred === 'boolean' ? source.starred : null, rawHash, payload: json(source), hasDescriptiveFacts,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function upsertEffort(database, payload, activitySourceId, rawHash, history) {
|
|
42
|
+
const effortId = text(payload.id);
|
|
43
|
+
const segment = object(payload.segment);
|
|
44
|
+
const segmentId = text(segment.id) ?? text(payload.segment_id);
|
|
45
|
+
if (!effortId || !segmentId)
|
|
46
|
+
return;
|
|
47
|
+
await upsertSegment(database, segment, rawHash);
|
|
48
|
+
const values = {
|
|
49
|
+
effortId, segmentId, elapsedS: number(payload.elapsed_time), movingS: number(payload.moving_time), distanceM: number(payload.distance), averageWatts: number(payload.average_watts), averageHr: number(payload.average_heartrate), maxHr: number(payload.max_heartrate), averageCadence: number(payload.average_cadence), deviceWatts: typeof payload.device_watts === 'boolean' ? payload.device_watts : null, prRank: number(payload.pr_rank), komRank: number(payload.kom_rank), startedAt: text(payload.start_date), rawHash, payload: json(payload),
|
|
50
|
+
};
|
|
51
|
+
if (activitySourceId) {
|
|
52
|
+
await database.run(`INSERT INTO activity_segments VALUES ($activitySourceId, $effortId, $segmentId, $elapsedS, $movingS, $distanceM, $averageWatts, $averageHr, $maxHr, $averageCadence, $deviceWatts, $prRank, $komRank, $startedAt, $rawHash, $payload)
|
|
53
|
+
ON CONFLICT (activity_source_id, effort_id) DO UPDATE SET segment_id = excluded.segment_id, elapsed_s = excluded.elapsed_s, moving_s = excluded.moving_s, distance_m = excluded.distance_m, average_watts = excluded.average_watts, average_hr = excluded.average_hr, max_hr = excluded.max_hr, average_cadence = excluded.average_cadence, device_watts = excluded.device_watts, pr_rank = excluded.pr_rank, kom_rank = excluded.kom_rank, started_at = excluded.started_at, raw_object_hash = excluded.raw_object_hash, payload_json = excluded.payload_json`, { activitySourceId, ...values });
|
|
54
|
+
}
|
|
55
|
+
if (history) {
|
|
56
|
+
const activity = object(payload.activity);
|
|
57
|
+
await database.run(`INSERT INTO strava_segment_efforts (effort_id, segment_id, strava_activity_id, elapsed_s, moving_s, distance_m, average_watts, average_hr, max_hr, average_cadence, device_watts, pr_rank, kom_rank, started_at, raw_object_hash, payload_json)
|
|
58
|
+
VALUES ($effortId, $segmentId, $activityId, $elapsedS, $movingS, $distanceM, $averageWatts, $averageHr, $maxHr, $averageCadence, $deviceWatts, $prRank, $komRank, $startedAt, $rawHash, $payload)
|
|
59
|
+
ON CONFLICT (effort_id) DO UPDATE SET segment_id = excluded.segment_id, strava_activity_id = excluded.strava_activity_id, elapsed_s = excluded.elapsed_s, moving_s = excluded.moving_s, distance_m = excluded.distance_m, average_watts = excluded.average_watts, average_hr = excluded.average_hr, max_hr = excluded.max_hr, average_cadence = excluded.average_cadence, device_watts = excluded.device_watts, pr_rank = excluded.pr_rank, kom_rank = excluded.kom_rank, started_at = excluded.started_at, raw_object_hash = excluded.raw_object_hash, payload_json = excluded.payload_json, updated_at = now()`, { activityId: text(activity.id) ?? text(payload.activity_id), ...values });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function normalizeStravaEntity(database, entity) {
|
|
63
|
+
const payload = object(entity.payload);
|
|
64
|
+
await storeGenericEntity(database, entity, payload);
|
|
65
|
+
if (entity.entityType === 'athlete') {
|
|
66
|
+
const accountId = text(payload.id) ?? entity.remoteId;
|
|
67
|
+
await database.insertSourceAccount('strava', accountId, [text(payload.firstname), text(payload.lastname)].filter(Boolean).join(' ') || null, payload);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (entity.entityType === 'segment')
|
|
71
|
+
return upsertSegment(database, payload, entity.rawObjectHash);
|
|
72
|
+
if (entity.entityType === 'segment_effort')
|
|
73
|
+
return upsertEffort(database, payload, null, entity.rawObjectHash, true);
|
|
74
|
+
if (entity.entityType !== 'activity')
|
|
75
|
+
return;
|
|
76
|
+
const activitySourceId = `strava:${entity.remoteId}`;
|
|
77
|
+
const sport = text(payload.sport_type) ?? text(payload.type);
|
|
78
|
+
await database.run(`INSERT INTO activities (activity_id, started_at_utc, started_at_local, timezone, sport, name, link_state)
|
|
79
|
+
VALUES ($activityId, $startedAt, $startedAtLocal, $timezone, $sport, $name, 'unlinked')
|
|
80
|
+
ON CONFLICT (activity_id) DO UPDATE SET started_at_utc = excluded.started_at_utc, started_at_local = excluded.started_at_local, timezone = excluded.timezone, sport = excluded.sport, name = excluded.name`, { activityId: activitySourceId, startedAt: text(payload.start_date), startedAtLocal: text(payload.start_date_local), timezone: text(payload.timezone), sport, name: text(payload.name) });
|
|
81
|
+
await database.run(`INSERT INTO activity_sources (activity_source_id, activity_id, provider, remote_activity_id, external_id, raw_object_hash)
|
|
82
|
+
VALUES ($activitySourceId, $activityId, 'strava', $remoteId, NULL, $rawHash)
|
|
83
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, raw_object_hash = excluded.raw_object_hash`, { activitySourceId, activityId: activitySourceId, remoteId: entity.remoteId, rawHash: entity.rawObjectHash });
|
|
84
|
+
await database.run(`INSERT INTO activity_summaries VALUES ($activitySourceId, $distanceM, $movingS, $elapsedS, $elevationGain, $calories, $avgHr, $maxHr, $avgPower, NULL, $avgCadence, NULL, NULL, NULL, $metrics)
|
|
85
|
+
ON CONFLICT (activity_source_id) DO UPDATE SET distance_m = excluded.distance_m, moving_s = excluded.moving_s, elapsed_s = excluded.elapsed_s, elevation_gain_m = excluded.elevation_gain_m, calories = excluded.calories, avg_hr = excluded.avg_hr, max_hr = excluded.max_hr, avg_power = excluded.avg_power, avg_cadence = excluded.avg_cadence, metrics_json = excluded.metrics_json`, { activitySourceId, distanceM: number(payload.distance), movingS: number(payload.moving_time), elapsedS: number(payload.elapsed_time), elevationGain: number(payload.total_elevation_gain), calories: number(payload.calories), avgHr: number(payload.average_heartrate), maxHr: number(payload.max_heartrate), avgPower: number(payload.average_watts), avgCadence: number(payload.average_cadence), metrics: json(payload) });
|
|
86
|
+
const efforts = Array.isArray(payload.segment_efforts) ? payload.segment_efforts : [];
|
|
87
|
+
for (const effort of efforts)
|
|
88
|
+
await upsertEffort(database, object(effort), activitySourceId, entity.rawObjectHash, false);
|
|
89
|
+
const gearId = text(payload.gear_id);
|
|
90
|
+
if (gearId)
|
|
91
|
+
await database.run(`INSERT INTO domain_entities (provider, entity_type, remote_id, parent_remote_id, occurred_on, payload_json, extension_json, raw_object_hash) VALUES ('strava', 'activity_gear', $gearId, $activityId, $occurredOn, $payload, '{}', $rawHash) ON CONFLICT (provider, entity_type, remote_id) DO UPDATE SET parent_remote_id = excluded.parent_remote_id, payload_json = excluded.payload_json, raw_object_hash = excluded.raw_object_hash`, { gearId, activityId: entity.remoteId, occurredOn: dateOnly(payload.start_date), payload: json({ gear_id: gearId, activity_id: entity.remoteId }), rawHash: entity.rawObjectHash });
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=strava-normalizer.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
2
|
+
import type { Provider } from '../../contracts/staging.js';
|
|
3
|
+
export declare function sha256(contents: string | Uint8Array): string;
|
|
4
|
+
/** Stable object hash used to decide whether expensive activity children changed. */
|
|
5
|
+
export declare function stableJsonHash(value: unknown): string;
|
|
6
|
+
export type StoredArtifact = {
|
|
7
|
+
contentHash: string;
|
|
8
|
+
relativePath: string;
|
|
9
|
+
contentType: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function storeArtifact(paths: CatencePaths, provider: Provider, endpoint: string, remoteId: string | null, contents: string | Uint8Array, extension: string, contentType: string): Promise<StoredArtifact>;
|
|
12
|
+
export declare function storeJsonArtifact(paths: CatencePaths, provider: Provider, endpoint: string, remoteId: string | null, payload: unknown): Promise<StoredArtifact>;
|
|
13
|
+
export declare function appendJsonl(filePath: string, value: unknown): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function safeSegment(value) {
|
|
5
|
+
return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_').slice(0, 160) || 'unknown';
|
|
6
|
+
}
|
|
7
|
+
export function sha256(contents) {
|
|
8
|
+
return createHash('sha256').update(contents).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
/** Stable object hash used to decide whether expensive activity children changed. */
|
|
11
|
+
export function stableJsonHash(value) {
|
|
12
|
+
return sha256(JSON.stringify(canonicalize(value)));
|
|
13
|
+
}
|
|
14
|
+
function canonicalize(value) {
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return value.map(canonicalize);
|
|
17
|
+
if (value && typeof value === 'object') {
|
|
18
|
+
return Object.fromEntries(Object.entries(value)
|
|
19
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
20
|
+
.map(([key, child]) => [key, canonicalize(child)]));
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export async function storeArtifact(paths, provider, endpoint, remoteId, contents, extension, contentType) {
|
|
25
|
+
const contentHash = sha256(contents);
|
|
26
|
+
const relativePath = path.join(provider, safeSegment(endpoint), safeSegment(remoteId ?? 'collection'), `${contentHash}.${extension}`);
|
|
27
|
+
const destination = path.join(paths.raw, relativePath);
|
|
28
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
29
|
+
try {
|
|
30
|
+
await readFile(destination);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
34
|
+
await writeFile(temporary, contents);
|
|
35
|
+
await rename(temporary, destination);
|
|
36
|
+
}
|
|
37
|
+
return { contentHash, relativePath: path.join('raw', relativePath), contentType };
|
|
38
|
+
}
|
|
39
|
+
export async function storeJsonArtifact(paths, provider, endpoint, remoteId, payload) {
|
|
40
|
+
const contents = `${JSON.stringify(payload, null, 2)}\n`;
|
|
41
|
+
return storeArtifact(paths, provider, endpoint, remoteId, contents, 'json', 'application/json');
|
|
42
|
+
}
|
|
43
|
+
export async function appendJsonl(filePath, value) {
|
|
44
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
45
|
+
const existing = await readFile(filePath, 'utf8').catch(() => '');
|
|
46
|
+
await writeFile(filePath, `${existing}${JSON.stringify(value)}\n`);
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=artifacts.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
2
|
+
import type { CatencePaths } from '../../contracts/runtime.js';
|
|
3
|
+
import type { QueryBindings, WriteDataStore } from '../../contracts/storage.js';
|
|
4
|
+
import type { Provider } from '../../contracts/staging.js';
|
|
5
|
+
export declare class CatenceDatabase implements WriteDataStore {
|
|
6
|
+
private readonly instance;
|
|
7
|
+
private readonly connection;
|
|
8
|
+
private closed;
|
|
9
|
+
private constructor();
|
|
10
|
+
static open(paths: CatencePaths): Promise<CatenceDatabase>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
migrate(): Promise<void>;
|
|
13
|
+
run(sql: string, values?: QueryBindings): Promise<void>;
|
|
14
|
+
rows<T extends Record<string, unknown>>(sql: string, values?: QueryBindings): Promise<T[]>;
|
|
15
|
+
tableNames(query: string): Promise<string[]>;
|
|
16
|
+
interrupt(): void;
|
|
17
|
+
beginRun(provider: Provider, fromDate: string, runId?: `${string}-${string}-${string}-${string}-${string}`): Promise<string>;
|
|
18
|
+
finishRun(runId: string): Promise<void>;
|
|
19
|
+
addError(runId: string, provider: Provider, endpoint: string, remoteId: string | null, message: string, retryable: boolean): Promise<void>;
|
|
20
|
+
status(): Promise<Record<string, unknown>>;
|
|
21
|
+
getRun(runId: string): Promise<{
|
|
22
|
+
provider: Provider;
|
|
23
|
+
from_date: string;
|
|
24
|
+
} | null>;
|
|
25
|
+
insertSourceAccount(provider: Provider, remoteAccountId: string, displayName: string | null, payload: unknown): Promise<void>;
|
|
26
|
+
resolveIncrementalWindow(provider: Provider, cursorName: 'daily' | 'activities', toDate: string, initialFromDate: string, lookbackDays: number): Promise<{
|
|
27
|
+
fromDate: string;
|
|
28
|
+
toDate: string;
|
|
29
|
+
source: 'cursor' | 'bootstrap' | 'initial';
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Return the uncovered historical portion of an explicit backfill range.
|
|
33
|
+
*
|
|
34
|
+
* A backfill is normally used to extend history backwards from data already
|
|
35
|
+
* present locally. Its newest normalized source date is therefore a
|
|
36
|
+
* coverage boundary: re-reading that day and everything after it only
|
|
37
|
+
* creates provider traffic and upserts that the caller did not request.
|
|
38
|
+
* Callers can opt into the complete explicit range with `includeExisting`.
|
|
39
|
+
*/
|
|
40
|
+
resolveBackfillWindow(provider: Provider, cursorName: 'daily' | 'activities', fromDate: string, toDate: string, includeExisting?: boolean): Promise<{
|
|
41
|
+
fromDate: string;
|
|
42
|
+
toDate: string;
|
|
43
|
+
} | null>;
|
|
44
|
+
advanceIncrementalCursor(provider: Provider, cursorName: 'daily' | 'activities', runId: string, coveredThroughDate: string, lookbackDays: number): Promise<void>;
|
|
45
|
+
activityNeedsDetailSync(provider: Provider, remoteActivityId: string, summaryHash: string): Promise<boolean>;
|
|
46
|
+
recordActivitySyncState(provider: Provider, remoteActivityId: string, summaryHash: string, runId: string, detailsFetched: boolean): Promise<void>;
|
|
47
|
+
knownActivitySummaryHashes(provider: Provider): Promise<Record<string, string>>;
|
|
48
|
+
private latestSourceDate;
|
|
49
|
+
private upsertCursor;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A deliberately small read-only facade for the MCP/query process. It never
|
|
53
|
+
* applies migrations or exposes a mutating method, so an MCP process cannot
|
|
54
|
+
* turn a question into a DuckDB write.
|
|
55
|
+
*/
|
|
56
|
+
export declare class ReadOnlyCatenceDatabase {
|
|
57
|
+
private readonly instance;
|
|
58
|
+
private readonly connection;
|
|
59
|
+
private closed;
|
|
60
|
+
private queryTail;
|
|
61
|
+
private constructor();
|
|
62
|
+
static open(paths: CatencePaths): Promise<ReadOnlyCatenceDatabase>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
rows<T extends Record<string, unknown>>(sql: string, values?: QueryBindings): Promise<T[]>;
|
|
65
|
+
tableNames(query: string): Promise<string[]>;
|
|
66
|
+
interrupt(): void;
|
|
67
|
+
private withConnection;
|
|
68
|
+
}
|
|
69
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
70
|
+
export declare function openReadOnlyRepository(paths: CatencePaths): Promise<ReadOnlyRepository>;
|
|
71
|
+
export declare class ReadOnlyDatabaseError extends Error {
|
|
72
|
+
readonly code: 'data_sync_in_progress' | 'data_unavailable';
|
|
73
|
+
constructor(code: 'data_sync_in_progress' | 'data_unavailable', message: string);
|
|
74
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { DuckDBInstance } from '@duckdb/node-api';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { ReadOnlyRepository } from '../../core/query/repository.js';
|
|
5
|
+
import { migrations } from './migrations.js';
|
|
6
|
+
import { json } from './sql.js';
|
|
7
|
+
export class CatenceDatabase {
|
|
8
|
+
instance;
|
|
9
|
+
connection;
|
|
10
|
+
closed = false;
|
|
11
|
+
constructor(instance, connection) {
|
|
12
|
+
this.instance = instance;
|
|
13
|
+
this.connection = connection;
|
|
14
|
+
}
|
|
15
|
+
static async open(paths) {
|
|
16
|
+
const instance = await DuckDBInstance.create(paths.database);
|
|
17
|
+
const connection = await instance.connect();
|
|
18
|
+
const database = new CatenceDatabase(instance, connection);
|
|
19
|
+
await database.migrate();
|
|
20
|
+
return database;
|
|
21
|
+
}
|
|
22
|
+
async close() {
|
|
23
|
+
if (this.closed)
|
|
24
|
+
return;
|
|
25
|
+
this.closed = true;
|
|
26
|
+
try {
|
|
27
|
+
// A connection must be disconnected before its owning instance. Keeping
|
|
28
|
+
// the instance alive for the full database lifetime also prevents the
|
|
29
|
+
// native binding from releasing it during an in-flight connection close.
|
|
30
|
+
this.connection.closeSync();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
this.instance.closeSync();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async migrate() {
|
|
37
|
+
await this.connection.run('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name VARCHAR NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())');
|
|
38
|
+
const rows = await this.rows('SELECT version FROM schema_migrations');
|
|
39
|
+
const applied = new Set(rows.map((row) => Number(row.version)));
|
|
40
|
+
for (const migration of migrations) {
|
|
41
|
+
if (applied.has(migration.version))
|
|
42
|
+
continue;
|
|
43
|
+
await this.connection.run('BEGIN TRANSACTION');
|
|
44
|
+
try {
|
|
45
|
+
await this.connection.run(migration.sql);
|
|
46
|
+
await this.connection.run('INSERT INTO schema_migrations (version, name) VALUES ($version, $name)', { version: migration.version, name: migration.name });
|
|
47
|
+
await this.connection.run('COMMIT');
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await this.connection.run('ROLLBACK');
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Some DuckDB builds abort while committing an index rebuild in the same
|
|
55
|
+
// transaction as an ALTER on its populated table. These statements are
|
|
56
|
+
// idempotent and retried on every open, so a process interruption after a
|
|
57
|
+
// migration commit cannot leave the database permanently under-indexed.
|
|
58
|
+
for (const migration of migrations) {
|
|
59
|
+
if (migration.postCommitSql)
|
|
60
|
+
await this.connection.run(migration.postCommitSql);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async run(sql, values) {
|
|
64
|
+
await this.connection.run(sql, values);
|
|
65
|
+
}
|
|
66
|
+
async rows(sql, values) {
|
|
67
|
+
const result = await this.connection.runAndReadAll(sql, values);
|
|
68
|
+
return result.getRowObjectsJS();
|
|
69
|
+
}
|
|
70
|
+
async tableNames(query) {
|
|
71
|
+
return [...this.connection.getTableNames(query, true)];
|
|
72
|
+
}
|
|
73
|
+
interrupt() {
|
|
74
|
+
this.connection.interrupt();
|
|
75
|
+
}
|
|
76
|
+
async beginRun(provider, fromDate, runId = randomUUID()) {
|
|
77
|
+
await this.run('INSERT INTO sync_runs (run_id, provider, from_date, started_at, status, parser_version) VALUES ($runId, $provider, $fromDate, now(), \'running\', 1)', { runId, provider, fromDate });
|
|
78
|
+
return runId;
|
|
79
|
+
}
|
|
80
|
+
async finishRun(runId) {
|
|
81
|
+
await this.run(`UPDATE sync_runs SET status = CASE WHEN error_count > 0 THEN 'completed_with_errors' ELSE 'completed' END, completed_at = now() WHERE run_id = $runId`, { runId });
|
|
82
|
+
await this.run("UPDATE retrieval_index_state SET status = 'stale' WHERE index_name = 'context'");
|
|
83
|
+
}
|
|
84
|
+
async addError(runId, provider, endpoint, remoteId, message, retryable) {
|
|
85
|
+
await this.run(`INSERT INTO normalization_errors (error_id, run_id, provider, endpoint, remote_id, message, retryable)
|
|
86
|
+
VALUES ($errorId, $runId, $provider, $endpoint, $remoteId, $message, $retryable)`, { errorId: randomUUID(), runId, provider, endpoint, remoteId, message, retryable });
|
|
87
|
+
await this.run('UPDATE sync_runs SET error_count = error_count + 1 WHERE run_id = $runId', { runId });
|
|
88
|
+
}
|
|
89
|
+
async status() {
|
|
90
|
+
// @duckdb/node-api owns native statement state on the connection. Keep
|
|
91
|
+
// status reads sequential instead of issuing several native calls at once.
|
|
92
|
+
const runs = await this.rows('SELECT status, count(*)::INTEGER AS count FROM sync_runs GROUP BY status');
|
|
93
|
+
const errors = await this.rows('SELECT count(*)::INTEGER AS count FROM normalization_errors WHERE resolved_at IS NULL');
|
|
94
|
+
const rawObjects = await this.rows('SELECT count(*)::INTEGER AS count FROM raw_objects');
|
|
95
|
+
const activities = await this.rows('SELECT count(*)::INTEGER AS count FROM activities');
|
|
96
|
+
const streams = await this.rows('SELECT count(*)::INTEGER AS count FROM stream_manifest');
|
|
97
|
+
const cursors = await this.rows('SELECT provider, cursor_name, cast(covered_through_date AS VARCHAR) AS covered_through_date, cast(latest_source_date AS VARCHAR) AS latest_source_date, lookback_days, last_successful_run_id, cast(last_completed_at AS VARCHAR) AS last_completed_at, status FROM sync_cursors ORDER BY provider, cursor_name');
|
|
98
|
+
return {
|
|
99
|
+
runs, unresolvedErrors: errors[0]?.count ?? 0, rawObjects: rawObjects[0]?.count ?? 0, activities: activities[0]?.count ?? 0, streams: streams[0]?.count ?? 0,
|
|
100
|
+
cursors: cursors.map((cursor) => ({ ...cursor, next_from_date: subtractDays(cursor.covered_through_date, Number(cursor.lookback_days)) })),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async getRun(runId) {
|
|
104
|
+
const rows = await this.rows('SELECT provider, cast(from_date AS VARCHAR) AS from_date FROM sync_runs WHERE run_id = $runId', { runId });
|
|
105
|
+
return rows[0] ?? null;
|
|
106
|
+
}
|
|
107
|
+
async insertSourceAccount(provider, remoteAccountId, displayName, payload) {
|
|
108
|
+
await this.run(`INSERT OR REPLACE INTO source_accounts VALUES ($provider, $remoteAccountId, $displayName, now(), $payload)`, { provider, remoteAccountId, displayName, payload: json(payload) });
|
|
109
|
+
}
|
|
110
|
+
async resolveIncrementalWindow(provider, cursorName, toDate, initialFromDate, lookbackDays) {
|
|
111
|
+
const cursor = await this.rows(`SELECT cast(covered_through_date AS VARCHAR) AS covered_through_date
|
|
112
|
+
FROM sync_cursors WHERE provider = $provider AND cursor_name = $cursorName`, { provider, cursorName });
|
|
113
|
+
if (cursor[0]?.covered_through_date) {
|
|
114
|
+
return { fromDate: subtractDays(cursor[0].covered_through_date, lookbackDays), toDate, source: 'cursor' };
|
|
115
|
+
}
|
|
116
|
+
const latestSourceDate = await this.latestSourceDate(provider, cursorName);
|
|
117
|
+
if (latestSourceDate) {
|
|
118
|
+
await this.upsertCursor(provider, cursorName, toDate, latestSourceDate, lookbackDays, null, 'bootstrap', { initializedFrom: 'normalized_source_dates' });
|
|
119
|
+
return { fromDate: subtractDays(latestSourceDate, lookbackDays), toDate, source: 'bootstrap' };
|
|
120
|
+
}
|
|
121
|
+
return { fromDate: initialFromDate, toDate, source: 'initial' };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Return the uncovered historical portion of an explicit backfill range.
|
|
125
|
+
*
|
|
126
|
+
* A backfill is normally used to extend history backwards from data already
|
|
127
|
+
* present locally. Its newest normalized source date is therefore a
|
|
128
|
+
* coverage boundary: re-reading that day and everything after it only
|
|
129
|
+
* creates provider traffic and upserts that the caller did not request.
|
|
130
|
+
* Callers can opt into the complete explicit range with `includeExisting`.
|
|
131
|
+
*/
|
|
132
|
+
async resolveBackfillWindow(provider, cursorName, fromDate, toDate, includeExisting = false) {
|
|
133
|
+
if (includeExisting)
|
|
134
|
+
return { fromDate, toDate };
|
|
135
|
+
const latestSourceDate = await this.latestSourceDate(provider, cursorName);
|
|
136
|
+
if (!latestSourceDate || latestSourceDate < fromDate)
|
|
137
|
+
return { fromDate, toDate };
|
|
138
|
+
if (latestSourceDate >= toDate)
|
|
139
|
+
return null;
|
|
140
|
+
const uncoveredThrough = subtractDays(latestSourceDate, 1);
|
|
141
|
+
return uncoveredThrough < fromDate ? null : { fromDate, toDate: uncoveredThrough };
|
|
142
|
+
}
|
|
143
|
+
async advanceIncrementalCursor(provider, cursorName, runId, coveredThroughDate, lookbackDays) {
|
|
144
|
+
const latestSourceDate = await this.latestSourceDate(provider, cursorName);
|
|
145
|
+
const run = await this.rows('SELECT error_count FROM sync_runs WHERE run_id = $runId', { runId });
|
|
146
|
+
const status = Number(run[0]?.error_count ?? 0) > 0 ? 'completed_with_errors' : 'completed';
|
|
147
|
+
await this.upsertCursor(provider, cursorName, coveredThroughDate, latestSourceDate, lookbackDays, runId, status, { reconciliation: 'overlap_window' });
|
|
148
|
+
}
|
|
149
|
+
async activityNeedsDetailSync(provider, remoteActivityId, summaryHash) {
|
|
150
|
+
const rows = await this.rows('SELECT summary_hash FROM activity_sync_state WHERE provider = $provider AND remote_activity_id = $remoteActivityId', { provider, remoteActivityId });
|
|
151
|
+
return rows[0]?.summary_hash !== summaryHash;
|
|
152
|
+
}
|
|
153
|
+
async recordActivitySyncState(provider, remoteActivityId, summaryHash, runId, detailsFetched) {
|
|
154
|
+
await this.run(`INSERT INTO activity_sync_state (provider, remote_activity_id, summary_hash, last_seen_run_id, last_detail_sync_at, details_status)
|
|
155
|
+
VALUES ($provider, $remoteActivityId, $summaryHash, $runId, CASE WHEN $detailsFetched THEN now() ELSE NULL END, CASE WHEN $detailsFetched THEN 'attempted' ELSE 'unchanged' END)
|
|
156
|
+
ON CONFLICT (provider, remote_activity_id) DO UPDATE SET
|
|
157
|
+
summary_hash = excluded.summary_hash,
|
|
158
|
+
last_seen_run_id = excluded.last_seen_run_id,
|
|
159
|
+
last_seen_at = now(),
|
|
160
|
+
last_detail_sync_at = CASE WHEN $detailsFetched THEN now() ELSE activity_sync_state.last_detail_sync_at END,
|
|
161
|
+
details_status = CASE WHEN $detailsFetched THEN 'attempted' ELSE activity_sync_state.details_status END`, { provider, remoteActivityId, summaryHash, runId, detailsFetched });
|
|
162
|
+
}
|
|
163
|
+
async knownActivitySummaryHashes(provider) {
|
|
164
|
+
const rows = await this.rows('SELECT remote_activity_id, summary_hash FROM activity_sync_state WHERE provider = $provider', { provider });
|
|
165
|
+
return Object.fromEntries(rows.map((row) => [row.remote_activity_id, row.summary_hash]));
|
|
166
|
+
}
|
|
167
|
+
async latestSourceDate(provider, cursorName) {
|
|
168
|
+
const observations = cursorName === 'daily'
|
|
169
|
+
? `SELECT max(metric_date) AS observed_date FROM daily_metrics WHERE provider = $provider
|
|
170
|
+
UNION ALL SELECT max(nutrition_date) FROM nutrition_days WHERE provider = $provider
|
|
171
|
+
UNION ALL SELECT max(occurred_on) FROM source_entities WHERE provider = $provider AND entity_type IN ('wellness', 'daily_health', 'nutrition_day', 'nutrition_log')`
|
|
172
|
+
: `SELECT max(cast(started_at_utc AS DATE)) AS observed_date FROM activities activity
|
|
173
|
+
JOIN activity_sources source USING (activity_id) WHERE source.provider = $provider
|
|
174
|
+
UNION ALL SELECT max(occurred_on) FROM source_entities WHERE provider = $provider AND entity_type = 'activity'`;
|
|
175
|
+
const rows = await this.rows(`
|
|
176
|
+
SELECT max(observed_date) AS latest_source_date FROM (${observations})
|
|
177
|
+
`, { provider });
|
|
178
|
+
return toIsoDate(rows[0]?.latest_source_date ?? null);
|
|
179
|
+
}
|
|
180
|
+
async upsertCursor(provider, cursorName, coveredThroughDate, latestSourceDate, lookbackDays, runId, status, detail) {
|
|
181
|
+
await this.run(`INSERT INTO sync_cursors (provider, cursor_name, covered_through_date, latest_source_date, lookback_days, last_successful_run_id, last_completed_at, status, detail_json)
|
|
182
|
+
VALUES ($provider, $cursorName, $coveredThroughDate, $latestSourceDate, $lookbackDays, $runId, now(), $status, $detail)
|
|
183
|
+
ON CONFLICT (provider, cursor_name) DO UPDATE SET
|
|
184
|
+
covered_through_date = excluded.covered_through_date,
|
|
185
|
+
latest_source_date = excluded.latest_source_date,
|
|
186
|
+
lookback_days = excluded.lookback_days,
|
|
187
|
+
last_successful_run_id = excluded.last_successful_run_id,
|
|
188
|
+
last_completed_at = excluded.last_completed_at,
|
|
189
|
+
status = excluded.status,
|
|
190
|
+
detail_json = excluded.detail_json`, { provider, cursorName, coveredThroughDate, latestSourceDate, lookbackDays, runId, status, detail: json(detail) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function toIsoDate(value) {
|
|
194
|
+
if (value instanceof Date)
|
|
195
|
+
return value.toISOString().slice(0, 10);
|
|
196
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))
|
|
197
|
+
return value.slice(0, 10);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
function subtractDays(date, days) {
|
|
201
|
+
const timestamp = Date.parse(`${date}T00:00:00Z`);
|
|
202
|
+
return new Date(timestamp - days * 86_400_000).toISOString().slice(0, 10);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* A deliberately small read-only facade for the MCP/query process. It never
|
|
206
|
+
* applies migrations or exposes a mutating method, so an MCP process cannot
|
|
207
|
+
* turn a question into a DuckDB write.
|
|
208
|
+
*/
|
|
209
|
+
export class ReadOnlyCatenceDatabase {
|
|
210
|
+
instance;
|
|
211
|
+
connection;
|
|
212
|
+
closed = false;
|
|
213
|
+
// @duckdb/node-api shares prepared-statement state on a connection. Query
|
|
214
|
+
// services may compose independent reads with Promise.all(), so serialize at
|
|
215
|
+
// this boundary instead of relying on every caller to remember that detail.
|
|
216
|
+
queryTail = Promise.resolve();
|
|
217
|
+
constructor(instance, connection) {
|
|
218
|
+
this.instance = instance;
|
|
219
|
+
this.connection = connection;
|
|
220
|
+
}
|
|
221
|
+
static async open(paths) {
|
|
222
|
+
if (!existsSync(paths.database)) {
|
|
223
|
+
throw new ReadOnlyDatabaseError('data_unavailable', 'No Catence database exists yet. Complete a sync before starting MCP.');
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const instance = await DuckDBInstance.create(paths.database, { access_mode: 'READ_ONLY' });
|
|
227
|
+
const connection = await instance.connect();
|
|
228
|
+
const database = new ReadOnlyCatenceDatabase(instance, connection);
|
|
229
|
+
const migration = await database.rows('SELECT max(version) AS version FROM schema_migrations');
|
|
230
|
+
const currentVersion = Number(migration[0]?.version ?? 0);
|
|
231
|
+
const requiredVersion = Math.max(...migrations.map((item) => item.version));
|
|
232
|
+
if (currentVersion < requiredVersion) {
|
|
233
|
+
await database.close();
|
|
234
|
+
throw new ReadOnlyDatabaseError('data_unavailable', `Database schema is at version ${currentVersion}; version ${requiredVersion} is required. Run a sync or build-retrieval-index once.`);
|
|
235
|
+
}
|
|
236
|
+
return database;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error instanceof ReadOnlyDatabaseError)
|
|
240
|
+
throw error;
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
const code = /lock|busy|conflict|read.?only|another process/i.test(message) ? 'data_sync_in_progress' : 'data_unavailable';
|
|
243
|
+
throw new ReadOnlyDatabaseError(code, `Read-only data snapshot is unavailable: ${message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async close() {
|
|
247
|
+
if (this.closed)
|
|
248
|
+
return;
|
|
249
|
+
this.closed = true;
|
|
250
|
+
await this.queryTail;
|
|
251
|
+
try {
|
|
252
|
+
this.connection.closeSync();
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.instance.closeSync();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async rows(sql, values) {
|
|
259
|
+
return this.withConnection(async () => {
|
|
260
|
+
const result = await this.connection.runAndReadAll(sql, values);
|
|
261
|
+
return result.getRowObjectsJS();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async tableNames(query) {
|
|
265
|
+
return this.withConnection(async () => [...this.connection.getTableNames(query, true)]);
|
|
266
|
+
}
|
|
267
|
+
interrupt() {
|
|
268
|
+
this.connection.interrupt();
|
|
269
|
+
}
|
|
270
|
+
async withConnection(operation) {
|
|
271
|
+
let release;
|
|
272
|
+
const completed = new Promise((resolve) => { release = resolve; });
|
|
273
|
+
const previous = this.queryTail;
|
|
274
|
+
this.queryTail = completed;
|
|
275
|
+
await previous;
|
|
276
|
+
try {
|
|
277
|
+
return await operation();
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
release?.();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** Compose core read services with the DuckDB-backed, read-only adapter. */
|
|
285
|
+
export async function openReadOnlyRepository(paths) {
|
|
286
|
+
return new ReadOnlyRepository(await ReadOnlyCatenceDatabase.open(paths), paths);
|
|
287
|
+
}
|
|
288
|
+
export class ReadOnlyDatabaseError extends Error {
|
|
289
|
+
code;
|
|
290
|
+
constructor(code, message) {
|
|
291
|
+
super(message);
|
|
292
|
+
this.code = code;
|
|
293
|
+
this.name = 'ReadOnlyDatabaseError';
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=database.js.map
|