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.
Files changed (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,183 @@
1
+ import { z } from 'zod';
2
+ export declare const PROVIDERS: readonly ["intervals", "garmin", "strava"];
3
+ export type Provider = (typeof PROVIDERS)[number];
4
+ export declare const STAGING_SCHEMA_VERSION = 1;
5
+ export declare const rawObjectSchema: z.ZodObject<{
6
+ kind: z.ZodLiteral<"raw_object">;
7
+ schemaVersion: z.ZodLiteral<1>;
8
+ provider: z.ZodEnum<{
9
+ intervals: "intervals";
10
+ garmin: "garmin";
11
+ strava: "strava";
12
+ }>;
13
+ endpoint: z.ZodString;
14
+ remoteId: z.ZodNullable<z.ZodString>;
15
+ fetchedAt: z.ZodString;
16
+ contentHash: z.ZodString;
17
+ contentType: z.ZodString;
18
+ relativePath: z.ZodString;
19
+ scope: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
20
+ }, z.core.$strip>;
21
+ export declare const runManifestSchema: z.ZodObject<{
22
+ kind: z.ZodLiteral<"run_manifest">;
23
+ schemaVersion: z.ZodLiteral<1>;
24
+ provider: z.ZodEnum<{
25
+ intervals: "intervals";
26
+ garmin: "garmin";
27
+ strava: "strava";
28
+ }>;
29
+ runId: z.ZodString;
30
+ fromDate: z.ZodString;
31
+ createdAt: z.ZodString;
32
+ }, z.core.$strip>;
33
+ export declare const sourceEntitySchema: z.ZodObject<{
34
+ kind: z.ZodLiteral<"source_entity">;
35
+ schemaVersion: z.ZodLiteral<1>;
36
+ provider: z.ZodEnum<{
37
+ intervals: "intervals";
38
+ garmin: "garmin";
39
+ strava: "strava";
40
+ }>;
41
+ entityType: z.ZodString;
42
+ remoteId: z.ZodString;
43
+ parentRemoteId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
44
+ occurredOn: z.ZodDefault<z.ZodNullable<z.ZodString>>;
45
+ sourceUpdatedAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
46
+ rawObjectHash: z.ZodDefault<z.ZodNullable<z.ZodString>>;
47
+ payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
48
+ extension: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
49
+ }, z.core.$strip>;
50
+ export declare const streamManifestSchema: z.ZodObject<{
51
+ kind: z.ZodLiteral<"stream_manifest">;
52
+ schemaVersion: z.ZodLiteral<1>;
53
+ provider: z.ZodEnum<{
54
+ intervals: "intervals";
55
+ garmin: "garmin";
56
+ strava: "strava";
57
+ }>;
58
+ activityRemoteId: z.ZodString;
59
+ relativePath: z.ZodString;
60
+ contentHash: z.ZodString;
61
+ rowCount: z.ZodNumber;
62
+ startAt: z.ZodNullable<z.ZodString>;
63
+ endAt: z.ZodNullable<z.ZodString>;
64
+ columns: z.ZodArray<z.ZodString>;
65
+ rawObjectHash: z.ZodDefault<z.ZodNullable<z.ZodString>>;
66
+ }, z.core.$strip>;
67
+ export declare const activitySyncStateSchema: z.ZodObject<{
68
+ kind: z.ZodLiteral<"activity_sync_state">;
69
+ schemaVersion: z.ZodLiteral<1>;
70
+ provider: z.ZodEnum<{
71
+ intervals: "intervals";
72
+ garmin: "garmin";
73
+ strava: "strava";
74
+ }>;
75
+ activityRemoteId: z.ZodString;
76
+ summaryHash: z.ZodString;
77
+ detailsFetched: z.ZodBoolean;
78
+ }, z.core.$strip>;
79
+ export declare const extractionErrorSchema: z.ZodObject<{
80
+ kind: z.ZodLiteral<"extraction_error">;
81
+ schemaVersion: z.ZodLiteral<1>;
82
+ provider: z.ZodEnum<{
83
+ intervals: "intervals";
84
+ garmin: "garmin";
85
+ strava: "strava";
86
+ }>;
87
+ endpoint: z.ZodString;
88
+ remoteId: z.ZodNullable<z.ZodString>;
89
+ message: z.ZodString;
90
+ retryable: z.ZodDefault<z.ZodBoolean>;
91
+ }, z.core.$strip>;
92
+ export declare const stagingRecordSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
93
+ kind: z.ZodLiteral<"run_manifest">;
94
+ schemaVersion: z.ZodLiteral<1>;
95
+ provider: z.ZodEnum<{
96
+ intervals: "intervals";
97
+ garmin: "garmin";
98
+ strava: "strava";
99
+ }>;
100
+ runId: z.ZodString;
101
+ fromDate: z.ZodString;
102
+ createdAt: z.ZodString;
103
+ }, z.core.$strip>, z.ZodObject<{
104
+ kind: z.ZodLiteral<"raw_object">;
105
+ schemaVersion: z.ZodLiteral<1>;
106
+ provider: z.ZodEnum<{
107
+ intervals: "intervals";
108
+ garmin: "garmin";
109
+ strava: "strava";
110
+ }>;
111
+ endpoint: z.ZodString;
112
+ remoteId: z.ZodNullable<z.ZodString>;
113
+ fetchedAt: z.ZodString;
114
+ contentHash: z.ZodString;
115
+ contentType: z.ZodString;
116
+ relativePath: z.ZodString;
117
+ scope: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
118
+ }, z.core.$strip>, z.ZodObject<{
119
+ kind: z.ZodLiteral<"source_entity">;
120
+ schemaVersion: z.ZodLiteral<1>;
121
+ provider: z.ZodEnum<{
122
+ intervals: "intervals";
123
+ garmin: "garmin";
124
+ strava: "strava";
125
+ }>;
126
+ entityType: z.ZodString;
127
+ remoteId: z.ZodString;
128
+ parentRemoteId: z.ZodDefault<z.ZodNullable<z.ZodString>>;
129
+ occurredOn: z.ZodDefault<z.ZodNullable<z.ZodString>>;
130
+ sourceUpdatedAt: z.ZodDefault<z.ZodNullable<z.ZodString>>;
131
+ rawObjectHash: z.ZodDefault<z.ZodNullable<z.ZodString>>;
132
+ payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
133
+ extension: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
134
+ }, z.core.$strip>, z.ZodObject<{
135
+ kind: z.ZodLiteral<"stream_manifest">;
136
+ schemaVersion: z.ZodLiteral<1>;
137
+ provider: z.ZodEnum<{
138
+ intervals: "intervals";
139
+ garmin: "garmin";
140
+ strava: "strava";
141
+ }>;
142
+ activityRemoteId: z.ZodString;
143
+ relativePath: z.ZodString;
144
+ contentHash: z.ZodString;
145
+ rowCount: z.ZodNumber;
146
+ startAt: z.ZodNullable<z.ZodString>;
147
+ endAt: z.ZodNullable<z.ZodString>;
148
+ columns: z.ZodArray<z.ZodString>;
149
+ rawObjectHash: z.ZodDefault<z.ZodNullable<z.ZodString>>;
150
+ }, z.core.$strip>, z.ZodObject<{
151
+ kind: z.ZodLiteral<"activity_sync_state">;
152
+ schemaVersion: z.ZodLiteral<1>;
153
+ provider: z.ZodEnum<{
154
+ intervals: "intervals";
155
+ garmin: "garmin";
156
+ strava: "strava";
157
+ }>;
158
+ activityRemoteId: z.ZodString;
159
+ summaryHash: z.ZodString;
160
+ detailsFetched: z.ZodBoolean;
161
+ }, z.core.$strip>, z.ZodObject<{
162
+ kind: z.ZodLiteral<"extraction_error">;
163
+ schemaVersion: z.ZodLiteral<1>;
164
+ provider: z.ZodEnum<{
165
+ intervals: "intervals";
166
+ garmin: "garmin";
167
+ strava: "strava";
168
+ }>;
169
+ endpoint: z.ZodString;
170
+ remoteId: z.ZodNullable<z.ZodString>;
171
+ message: z.ZodString;
172
+ retryable: z.ZodDefault<z.ZodBoolean>;
173
+ }, z.core.$strip>], "kind">;
174
+ export type StagingRecord = z.infer<typeof stagingRecordSchema>;
175
+ export type SourceEntity = z.infer<typeof sourceEntitySchema>;
176
+ export type EndpointSpec = {
177
+ readonly provider: Provider;
178
+ readonly name: string;
179
+ readonly scope: 'singleton' | 'date_range' | 'activity' | 'collection' | 'child';
180
+ readonly entityType: string;
181
+ readonly readOnly: true;
182
+ readonly pagination: 'none' | 'offset' | 'provider';
183
+ };
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ export const PROVIDERS = ['intervals', 'garmin', 'strava'];
3
+ export const STAGING_SCHEMA_VERSION = 1;
4
+ export const rawObjectSchema = z.object({
5
+ kind: z.literal('raw_object'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
6
+ endpoint: z.string().min(1), remoteId: z.string().nullable(), fetchedAt: z.string().datetime(),
7
+ contentHash: z.string().length(64), contentType: z.string().min(1), relativePath: z.string().min(1),
8
+ scope: z.record(z.string(), z.unknown()).default({}),
9
+ });
10
+ export const runManifestSchema = z.object({
11
+ kind: z.literal('run_manifest'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
12
+ runId: z.string().uuid(), fromDate: z.string().date(), createdAt: z.string().datetime(),
13
+ });
14
+ export const sourceEntitySchema = z.object({
15
+ kind: z.literal('source_entity'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
16
+ entityType: z.string().min(1), remoteId: z.string().min(1), parentRemoteId: z.string().nullable().default(null),
17
+ occurredOn: z.string().nullable().default(null), sourceUpdatedAt: z.string().nullable().default(null),
18
+ rawObjectHash: z.string().length(64).nullable().default(null), payload: z.record(z.string(), z.unknown()),
19
+ extension: z.record(z.string(), z.unknown()).default({}),
20
+ });
21
+ export const streamManifestSchema = z.object({
22
+ kind: z.literal('stream_manifest'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
23
+ activityRemoteId: z.string().min(1), relativePath: z.string().min(1), contentHash: z.string().length(64),
24
+ rowCount: z.number().int().nonnegative(), startAt: z.string().nullable(), endAt: z.string().nullable(),
25
+ columns: z.array(z.string()), rawObjectHash: z.string().length(64).nullable().default(null),
26
+ });
27
+ export const activitySyncStateSchema = z.object({
28
+ kind: z.literal('activity_sync_state'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
29
+ activityRemoteId: z.string().min(1), summaryHash: z.string().length(64), detailsFetched: z.boolean(),
30
+ });
31
+ export const extractionErrorSchema = z.object({
32
+ kind: z.literal('extraction_error'), schemaVersion: z.literal(STAGING_SCHEMA_VERSION), provider: z.enum(PROVIDERS),
33
+ endpoint: z.string().min(1), remoteId: z.string().nullable(), message: z.string().min(1), retryable: z.boolean().default(true),
34
+ });
35
+ export const stagingRecordSchema = z.discriminatedUnion('kind', [runManifestSchema, rawObjectSchema, sourceEntitySchema, streamManifestSchema, activitySyncStateSchema, extractionErrorSchema]);
36
+ //# sourceMappingURL=staging.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Storage boundary used by Catence core. Implementations may use DuckDB or a
3
+ * test double, but core logic does not depend on a database driver.
4
+ */
5
+ export type QueryValues = Record<string, unknown>;
6
+ export type QueryBindings = QueryValues | unknown[];
7
+ export interface ReadDataStore {
8
+ close(): Promise<void>;
9
+ rows<T extends Record<string, unknown>>(sql: string, values?: QueryBindings): Promise<T[]>;
10
+ tableNames(query: string): Promise<string[]>;
11
+ interrupt(): void;
12
+ }
13
+ export interface WriteDataStore extends ReadDataStore {
14
+ run(sql: string, values?: QueryBindings): Promise<void>;
15
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1,16 @@
1
+ import type { ReadOnlyRepository } from '../query/repository.js';
2
+ export type DashboardOptions = {
3
+ endDate: string;
4
+ days: number;
5
+ };
6
+ /**
7
+ * Fixed dashboard facts for the local Console.
8
+ *
9
+ * The service deliberately reads Catence's existing canonical projections
10
+ * instead of adding a visualization-specific store or materialized mirror.
11
+ */
12
+ export declare class DashboardSnapshotService {
13
+ private readonly repository;
14
+ constructor(repository: ReadOnlyRepository);
15
+ snapshot(options: DashboardOptions): Promise<Record<string, unknown>>;
16
+ }
@@ -0,0 +1,65 @@
1
+ function subtractDays(date, days) {
2
+ const value = new Date(`${date}T00:00:00.000Z`);
3
+ value.setUTCDate(value.getUTCDate() - days);
4
+ return value.toISOString().slice(0, 10);
5
+ }
6
+ /**
7
+ * Fixed dashboard facts for the local Console.
8
+ *
9
+ * The service deliberately reads Catence's existing canonical projections
10
+ * instead of adding a visualization-specific store or materialized mirror.
11
+ */
12
+ export class DashboardSnapshotService {
13
+ repository;
14
+ constructor(repository) {
15
+ this.repository = repository;
16
+ }
17
+ async snapshot(options) {
18
+ const startDate = subtractDays(options.endDate, options.days - 1);
19
+ // DuckDB's native Node binding shares statement state on one connection.
20
+ // Keep dashboard reads sequential just like the existing status service.
21
+ const health = await this.repository.rows(`
22
+ SELECT cast(metric_date AS VARCHAR) AS metric_date, provider,
23
+ resting_hr_bpm, hrv_ms, sleep_seconds, sleep_score, stress, body_battery, readiness, steps
24
+ FROM daily_health
25
+ WHERE metric_date BETWEEN $startDate AND $endDate
26
+ ORDER BY metric_date ASC
27
+ `, { startDate, endDate: options.endDate });
28
+ const weeklyTraining = await this.repository.rows(`
29
+ SELECT cast(cast(date_trunc('week', started_at_utc) AS DATE) AS VARCHAR) AS week_start,
30
+ count(*)::INTEGER AS activity_count,
31
+ sum(distance_m) AS distance_m,
32
+ sum(moving_s) AS moving_s,
33
+ sum(elevation_gain_m) AS elevation_gain_m,
34
+ sum(training_load) AS training_load
35
+ FROM canonical_activity_training
36
+ WHERE cast(started_at_utc AS DATE) BETWEEN $startDate AND $endDate
37
+ GROUP BY week_start
38
+ ORDER BY week_start ASC
39
+ `, { startDate, endDate: options.endDate });
40
+ const recentActivities = await this.repository.rows(`
41
+ SELECT activity_id, cast(started_at_utc AS VARCHAR) AS started_at_utc, sport, name,
42
+ distance_m, moving_s, elevation_gain_m, training_load, provider
43
+ FROM canonical_activity_training
44
+ WHERE cast(started_at_utc AS DATE) <= $endDate
45
+ ORDER BY started_at_utc DESC
46
+ LIMIT 12
47
+ `, { endDate: options.endDate });
48
+ const status = await this.repository.status();
49
+ const coverage = await this.repository.coverage();
50
+ return {
51
+ generatedAt: new Date().toISOString(),
52
+ period: { startDate, endDate: options.endDate, days: options.days },
53
+ health,
54
+ training: { weeks: weeklyTraining },
55
+ activities: recentActivities,
56
+ status,
57
+ coverage,
58
+ caveats: [
59
+ 'Dashboard values are read from Catence canonical daily-health and activity projections.',
60
+ 'Missing metrics or periods are returned as null or absent rows and are never estimated for visualization.',
61
+ ],
62
+ };
63
+ }
64
+ }
65
+ //# sourceMappingURL=snapshot.js.map
@@ -0,0 +1,21 @@
1
+ import { ReadOnlyRepository } from './repository.js';
2
+ export type FindActivitiesRequest = {
3
+ sports?: string[];
4
+ distanceKm?: [number, number];
5
+ nameContains?: string[];
6
+ startDate?: string;
7
+ endDate?: string;
8
+ sort?: 'date_desc' | 'date_asc';
9
+ limit?: number;
10
+ cursor?: string;
11
+ };
12
+ /**
13
+ * Finds one canonical source-backed activity per logical activity. Race
14
+ * classification is transparent heuristics, not a claim that a provider
15
+ * recorded an event as a race.
16
+ */
17
+ export declare class ActivityDiscoveryService {
18
+ private readonly repository;
19
+ constructor(repository: ReadOnlyRepository);
20
+ findActivities(request?: FindActivitiesRequest): Promise<Record<string, unknown>>;
21
+ }
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { QueryValidationError } from './catalog.js';
3
+ import { jsonSafe } from './repository.js';
4
+ function hash(request) {
5
+ return createHash('sha256').update(JSON.stringify(request)).digest('base64url');
6
+ }
7
+ function encodeCursor(cursor) {
8
+ return Buffer.from(JSON.stringify(cursor)).toString('base64url');
9
+ }
10
+ function decodeCursor(cursor, expectedHash) {
11
+ try {
12
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
13
+ if (!Number.isInteger(decoded.offset) || decoded.offset < 0 || decoded.hash !== expectedHash)
14
+ throw new Error();
15
+ return decoded;
16
+ }
17
+ catch {
18
+ throw new QueryValidationError('Invalid or mismatched cursor. Start the activity search again.');
19
+ }
20
+ }
21
+ function raceSignals(row, requestedDistance) {
22
+ const name = row.name?.toLowerCase() ?? '';
23
+ const nameSuggestsRace = /\b(race|marathon|half[ -]?marathon|media(?:[ -]?marat[oó]n)?|corrida|competition|10k|5k|hm)\b/i.test(name);
24
+ const distanceKm = row.distance_m === null ? null : row.distance_m / 1_000;
25
+ const uninterrupted = row.moving_s !== null && row.elapsed_s !== null && row.moving_s > 0
26
+ ? { ratio: row.moving_s / row.elapsed_s, likely: row.elapsed_s - row.moving_s <= 180 || row.moving_s / row.elapsed_s >= 0.97 }
27
+ : { ratio: null, likely: false };
28
+ const distanceMatchesRequestedRange = Boolean(distanceKm !== null && requestedDistance && distanceKm >= requestedDistance[0] && distanceKm <= requestedDistance[1]);
29
+ const hasHeartRate = row.avg_hr !== null || row.max_hr !== null;
30
+ const hasTrainingLoad = row.training_load !== null;
31
+ const likelyRace = nameSuggestsRace || (distanceMatchesRequestedRange && uninterrupted.likely && hasHeartRate && hasTrainingLoad);
32
+ return {
33
+ likelyRace,
34
+ nameSuggestsRace,
35
+ distanceMatchesRequestedRange,
36
+ uninterruptedMovingTime: uninterrupted,
37
+ heartRateAvailable: hasHeartRate,
38
+ trainingLoadAvailable: hasTrainingLoad,
39
+ };
40
+ }
41
+ /**
42
+ * Finds one canonical source-backed activity per logical activity. Race
43
+ * classification is transparent heuristics, not a claim that a provider
44
+ * recorded an event as a race.
45
+ */
46
+ export class ActivityDiscoveryService {
47
+ repository;
48
+ constructor(repository) {
49
+ this.repository = repository;
50
+ }
51
+ async findActivities(request = {}) {
52
+ const sports = [...new Set((request.sports ?? []).map((sport) => sport.trim()).filter(Boolean))];
53
+ const names = [...new Set((request.nameContains ?? []).map((name) => name.trim()).filter(Boolean))];
54
+ if (sports.length > 12 || names.length > 12)
55
+ throw new QueryValidationError('sports and nameContains each permit at most 12 values.');
56
+ if (request.distanceKm && (!Number.isFinite(request.distanceKm[0]) || !Number.isFinite(request.distanceKm[1]) || request.distanceKm[0] < 0 || request.distanceKm[0] > request.distanceKm[1])) {
57
+ throw new QueryValidationError('distanceKm must contain an ascending, non-negative [minimum, maximum] range.');
58
+ }
59
+ const limit = Math.min(Math.max(request.limit ?? 50, 1), 100);
60
+ const { cursor: ignoredCursor, limit: ignoredLimit, ...fingerprintRequest } = request;
61
+ void ignoredCursor;
62
+ void ignoredLimit;
63
+ const fingerprint = hash(fingerprintRequest);
64
+ const offset = request.cursor ? decodeCursor(request.cursor, fingerprint).offset : 0;
65
+ const values = {};
66
+ const clauses = [];
67
+ if (sports.length) {
68
+ const placeholders = sports.map((sport, index) => {
69
+ const key = `sport${index}`;
70
+ values[key] = sport;
71
+ return `lower(activity.sport) = lower($${key})`;
72
+ });
73
+ clauses.push(`(${placeholders.join(' OR ')})`);
74
+ }
75
+ if (request.distanceKm) {
76
+ values.minDistanceM = request.distanceKm[0] * 1_000;
77
+ values.maxDistanceM = request.distanceKm[1] * 1_000;
78
+ clauses.push('summary.distance_m BETWEEN $minDistanceM AND $maxDistanceM');
79
+ }
80
+ if (names.length) {
81
+ const placeholders = names.map((name, index) => {
82
+ const key = `name${index}`;
83
+ values[key] = name;
84
+ return `activity.name ILIKE '%' || $${key} || '%'`;
85
+ });
86
+ clauses.push(`(${placeholders.join(' OR ')})`);
87
+ }
88
+ if (request.startDate) {
89
+ values.startDate = request.startDate;
90
+ clauses.push('activity.started_at_utc >= cast($startDate AS TIMESTAMPTZ)');
91
+ }
92
+ if (request.endDate) {
93
+ values.endDate = `${request.endDate}T23:59:59.999Z`;
94
+ clauses.push('activity.started_at_utc <= cast($endDate AS TIMESTAMPTZ)');
95
+ }
96
+ const direction = request.sort === 'date_asc' ? 'ASC' : 'DESC';
97
+ const base = `
98
+ SELECT activity.activity_id, source.activity_source_id, source.provider,
99
+ cast(activity.started_at_utc AS VARCHAR) AS started_at_utc, activity.sport, activity.name,
100
+ summary.distance_m, summary.moving_s, summary.elapsed_s, summary.avg_hr, summary.max_hr,
101
+ coalesce(summary.training_load, canonical.intervals_training_load) AS training_load, activity.link_state
102
+ FROM activities AS activity
103
+ JOIN activity_sources AS source USING (activity_id)
104
+ LEFT JOIN activity_summaries AS summary USING (activity_source_id)
105
+ LEFT JOIN canonical_activity_training AS canonical ON canonical.activity_id = activity.activity_id
106
+ WHERE source.activity_source_id = canonical.activity_source_id
107
+ ${clauses.length ? `AND ${clauses.join(' AND ')}` : ''}
108
+ `;
109
+ // canonical_activity_training supplies the same canonical-source rule used
110
+ // by reports, so linked Garmin/Intervals duplicates appear once.
111
+ const total = await this.repository.rows(`SELECT count(*) AS total_rows FROM (${base}) AS matches`, values);
112
+ const totalRows = Number(total[0]?.total_rows ?? 0);
113
+ values.limit = limit;
114
+ values.offset = offset;
115
+ const rows = await this.repository.rows(`${base} ORDER BY started_at_utc ${direction}, activity_source_id ASC LIMIT $limit OFFSET $offset`, values);
116
+ const returnedRows = rows.length;
117
+ const truncated = offset + returnedRows < totalRows;
118
+ return jsonSafe({
119
+ returnedRows,
120
+ totalRows,
121
+ truncated,
122
+ nextCursor: truncated ? encodeCursor({ hash: fingerprint, offset: offset + returnedRows }) : undefined,
123
+ data: rows.map((row) => ({ ...row, raceSignals: raceSignals(row, request.distanceKm) })),
124
+ provenance: { relations: ['activities', 'activity_sources', 'activity_summaries', 'canonical_activity_training'], canonicalSource: true },
125
+ query: { sports, distanceKm: request.distanceKm, nameContains: names, startDate: request.startDate, endDate: request.endDate, sort: request.sort ?? 'date_desc', limit },
126
+ caveats: [
127
+ 'likelyRace is a transparent heuristic based on name, distance, uninterrupted moving time, heart-rate availability, and training-load availability; it is not provider-confirmed race metadata.',
128
+ ...(truncated ? ['Results are incomplete. Do not draw exhaustive conclusions; retrieve remaining pages with nextCursor or narrow the search.'] : []),
129
+ ],
130
+ });
131
+ }
132
+ }
133
+ //# sourceMappingURL=activity-discovery.js.map
@@ -0,0 +1,59 @@
1
+ import type { QueryValues } from '../../contracts/storage.js';
2
+ import { type DatasetDefinition } from './catalog.js';
3
+ import { ReadOnlyRepository } from './repository.js';
4
+ export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'between' | 'contains';
5
+ export type DataFilter = {
6
+ column: string;
7
+ op: FilterOperator;
8
+ value: unknown;
9
+ };
10
+ export type Resolution = 'raw' | '1m' | '5m' | '15m' | '1h' | 'day' | 'week' | 'auto';
11
+ export type SeriesRequest = {
12
+ dataset: string;
13
+ metrics: string[];
14
+ filters?: DataFilter[];
15
+ startDate?: string;
16
+ endDate?: string;
17
+ activityId?: string;
18
+ resolution?: Resolution;
19
+ cursor?: string;
20
+ pageSize?: number;
21
+ };
22
+ export declare function compileFilters(dataset: DatasetDefinition, filters?: DataFilter[], startDate?: string, endDate?: string, activityId?: string): {
23
+ where: string;
24
+ values: QueryValues;
25
+ };
26
+ export declare class AnalyticsService {
27
+ private readonly repository;
28
+ constructor(repository: ReadOnlyRepository);
29
+ readSeries(request: SeriesRequest): Promise<Record<string, unknown>>;
30
+ aggregate(request: {
31
+ dataset: string;
32
+ metrics: Array<{
33
+ column: string;
34
+ operation: 'count' | 'sum' | 'mean' | 'min' | 'max' | 'percentile';
35
+ percentile?: number;
36
+ as?: string;
37
+ }>;
38
+ dimensions?: string[];
39
+ filters?: DataFilter[];
40
+ startDate?: string;
41
+ endDate?: string;
42
+ timeBucket?: 'day' | 'week' | 'month';
43
+ orderBy?: {
44
+ column: string;
45
+ direction?: 'asc' | 'desc';
46
+ };
47
+ limit?: number;
48
+ }): Promise<Record<string, unknown>>;
49
+ analyzeSeries(request: SeriesRequest & {
50
+ analysis: 'rolling_mean' | 'rolling_median' | 'baseline_change' | 'z_score' | 'pearson' | 'spearman' | 'seasonal_comparison' | 'linear_trend' | 'theil_sen_trend';
51
+ compareMetric?: string;
52
+ window?: number;
53
+ }): Promise<Record<string, unknown>>;
54
+ fitSeriesModel(request: SeriesRequest & {
55
+ model: 'ols_linear' | 'theil_sen_linear' | 'polynomial_2' | 'polynomial_3';
56
+ xMetric?: string;
57
+ yMetric?: string;
58
+ }): Promise<Record<string, unknown>>;
59
+ }