@opendatalabs/personal-server-ts-core 1.25.1 → 1.27.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 (58) hide show
  1. package/dist/api/index.d.ts.map +1 -1
  2. package/dist/api/index.js +10 -0
  3. package/dist/api/index.js.map +1 -1
  4. package/dist/contracts/data.d.ts +5 -0
  5. package/dist/contracts/data.d.ts.map +1 -1
  6. package/dist/contracts/data.js +3 -1
  7. package/dist/contracts/data.js.map +1 -1
  8. package/dist/derivatives/scheduler.d.ts.map +1 -1
  9. package/dist/derivatives/scheduler.js +18 -1
  10. package/dist/derivatives/scheduler.js.map +1 -1
  11. package/dist/legacy-projection/bindings.d.ts +10 -0
  12. package/dist/legacy-projection/bindings.d.ts.map +1 -0
  13. package/dist/legacy-projection/bindings.js +3945 -0
  14. package/dist/legacy-projection/bindings.js.map +1 -0
  15. package/dist/legacy-projection/github-browser-bindings.d.ts +3 -0
  16. package/dist/legacy-projection/github-browser-bindings.d.ts.map +1 -0
  17. package/dist/legacy-projection/github-browser-bindings.js +128 -0
  18. package/dist/legacy-projection/github-browser-bindings.js.map +1 -0
  19. package/dist/legacy-projection/index.d.ts +33 -0
  20. package/dist/legacy-projection/index.d.ts.map +1 -0
  21. package/dist/legacy-projection/index.js +189 -0
  22. package/dist/legacy-projection/index.js.map +1 -0
  23. package/dist/legacy-projection/meta-040-bindings.d.ts +3 -0
  24. package/dist/legacy-projection/meta-040-bindings.d.ts.map +1 -0
  25. package/dist/legacy-projection/meta-040-bindings.js +112 -0
  26. package/dist/legacy-projection/meta-040-bindings.js.map +1 -0
  27. package/dist/legacy-projection/oura-browser-bindings.d.ts +3 -0
  28. package/dist/legacy-projection/oura-browser-bindings.d.ts.map +1 -0
  29. package/dist/legacy-projection/oura-browser-bindings.js +137 -0
  30. package/dist/legacy-projection/oura-browser-bindings.js.map +1 -0
  31. package/dist/legacy-projection/provenance.d.ts +57 -0
  32. package/dist/legacy-projection/provenance.d.ts.map +1 -0
  33. package/dist/legacy-projection/provenance.js +2 -0
  34. package/dist/legacy-projection/provenance.js.map +1 -0
  35. package/dist/legacy-projection/test-debris.d.ts +2 -0
  36. package/dist/legacy-projection/test-debris.d.ts.map +1 -0
  37. package/dist/legacy-projection/test-debris.js +49 -0
  38. package/dist/legacy-projection/test-debris.js.map +1 -0
  39. package/dist/legacy-projection/types.d.ts +167 -0
  40. package/dist/legacy-projection/types.d.ts.map +1 -0
  41. package/dist/legacy-projection/types.js +2 -0
  42. package/dist/legacy-projection/types.js.map +1 -0
  43. package/dist/ports/index.d.ts +6 -0
  44. package/dist/ports/index.d.ts.map +1 -1
  45. package/dist/ports/index.js.map +1 -1
  46. package/dist/storage/blocks/cursor.d.ts +5 -1
  47. package/dist/storage/blocks/cursor.d.ts.map +1 -1
  48. package/dist/storage/blocks/cursor.js +13 -1
  49. package/dist/storage/blocks/cursor.js.map +1 -1
  50. package/dist/storage/blocks/page.d.ts +18 -0
  51. package/dist/storage/blocks/page.d.ts.map +1 -0
  52. package/dist/storage/blocks/page.js +113 -0
  53. package/dist/storage/blocks/page.js.map +1 -0
  54. package/dist/storage/legacy-projection.d.ts +59 -0
  55. package/dist/storage/legacy-projection.d.ts.map +1 -0
  56. package/dist/storage/legacy-projection.js +371 -0
  57. package/dist/storage/legacy-projection.js.map +1 -0
  58. package/package.json +8 -1
@@ -0,0 +1,3945 @@
1
+ /**
2
+ * Every entry cites source evidence in ./declarations. Published OCI profiles
3
+ * carry immutable artifact digests; where the OCI manifest pin is unavailable,
4
+ * the generated collection-profile output digest is recorded explicitly. No
5
+ * entry here uses a fabricated field. Every legacy field
6
+ * this phase cannot reconstruct is a documented gap (either the whole scope
7
+ * is absent from
8
+ * LEGACY_SCOPE_BINDINGS and appears in the report's gap table, or the field
9
+ * is listed in the binding's `lossy` array and called out in this file's
10
+ * comments plus the report).
11
+ *
12
+ * `pdppSource` is each declaration's own `connector_id`
13
+ * (`https://registry.pdpp.dev/connectors/<key>`), verified byte-identical
14
+ * per source below — never the short `connector_key` a Personal Server does
15
+ * not key sources by.
16
+ *
17
+ * `fieldsRead` and `primaryKey` name exactly the declared fields each
18
+ * binding depends on, per bound stream. The self-check
19
+ * (bindings-self-check.test.ts) compares both against the vendored
20
+ * declaration's own `schema.properties` / `primary_key` — deleting or
21
+ * renaming a field this binding reads, or pointing `primaryKey` at the
22
+ * wrong (but still declared) field, must fail that check (S1).
23
+ */
24
+ function byStream(records, stream) {
25
+ return records.filter((r) => r.stream === stream).map((r) => r.data);
26
+ }
27
+ function isObjectRow(value) {
28
+ return value !== null && typeof value === "object" && !Array.isArray(value);
29
+ }
30
+ function ifDefined(record, sourceField, targetField) {
31
+ return record[sourceField] === undefined
32
+ ? {}
33
+ : { [targetField]: record[sourceField] };
34
+ }
35
+ function missingStream(scope, expectedStream) {
36
+ return {
37
+ ok: false,
38
+ error: { kind: "missing_stream", scope, expectedStream },
39
+ };
40
+ }
41
+ /**
42
+ * A stream the caller never fetched must be a `missing_stream` error, not
43
+ * silently indistinguishable from "fetched, zero rows" (a valid state for a
44
+ * list-shaped stream). "Fetched" is expressed explicitly via
45
+ * `options.fetchedStreams` — the set of streams the caller actually asked
46
+ * the Personal Server for — independent of how many records came back. A
47
+ * stream in `fetchedStreams` with zero matching `records` is a real, empty
48
+ * result (N-B1), not an error.
49
+ */
50
+ /**
51
+ * The timestamp a binding stamps on generated fields (`fetched_at`,
52
+ * `fetchedAt`, a null memory `created_at`). A caller that needs the same
53
+ * stored records to project to the same bytes passes `options.now`; without
54
+ * it the wall clock is used, as the legacy connectors did per run.
55
+ */
56
+ function projectionTime(options) {
57
+ return options.now ?? new Date().toISOString();
58
+ }
59
+ function requireStreams(scope, options, expectedStreams) {
60
+ for (const stream of expectedStreams) {
61
+ if (!options.fetchedStreams.includes(stream)) {
62
+ return missingStream(scope, stream);
63
+ }
64
+ }
65
+ return;
66
+ }
67
+ /**
68
+ * Legacy connectors that hit a missing/null value at the pin emit `''`
69
+ * (verified per call site: linkedin-playwright.js `pos.title || ''`,
70
+ * `edu.grade || ''`; amazon-playwright.js `price: ''`; shop-playwright.js
71
+ * DOM-fallback `orderNumber: ''`). A legacy-typed string field therefore
72
+ * defaults to `''`, matching the pinned connector's own behavior, so the
73
+ * projected payload never fails the legacy schema on a null/undefined PDPP
74
+ * field. This is real, cited legacy behavior, not an invented default.
75
+ */
76
+ function orEmptyString(value) {
77
+ return typeof value === "string" ? value : "";
78
+ }
79
+ /**
80
+ * Legacy connectors that hit a missing/null count emit `0`
81
+ * (linkedin-playwright.js `sk.endorsementCount || sk.endorsements || 0`).
82
+ */
83
+ function orZero(value) {
84
+ return typeof value === "number" ? value : 0;
85
+ }
86
+ function youtubeStringOrNull(value) {
87
+ return typeof value === "string" ? value : null;
88
+ }
89
+ function youtubeRequiredString(value) {
90
+ return typeof value === "string" && value.trim() ? value : undefined;
91
+ }
92
+ function youtubeLegacyProjection(scope, skipped, fields, payload) {
93
+ return {
94
+ ok: true,
95
+ payload,
96
+ ...(skipped > 0
97
+ ? {
98
+ diagnostics: [
99
+ {
100
+ kind: "records_skipped",
101
+ scope,
102
+ count: skipped,
103
+ missingFields: fields,
104
+ },
105
+ ],
106
+ }
107
+ : {}),
108
+ };
109
+ }
110
+ const youtubeProvenance = [
111
+ {
112
+ kind: "collection-profile",
113
+ ref: "https://github.com/PDP-Connect/data-connectors/tree/fe871c128acd801849551e1d75db0b570b5279df/connectors/youtube",
114
+ digest: "sha256:bc6bcb2704bd52d9c002eb0c1810bceb15c4a07469f07a326ff08553b3b5e41f",
115
+ path: "declarations/youtube.collection-profile.json#streams[name=profile,subscriptions,playlists,playlist_items,likes,watch_later,watch_history]",
116
+ },
117
+ ];
118
+ const youtubeProfile = {
119
+ scope: "youtube.profile",
120
+ pdppSource: "https://registry.pdpp.dev/connectors/youtube",
121
+ pdppStreams: ["profile"],
122
+ legacySchemaPath: "youtube.profile.json",
123
+ fieldsRead: {
124
+ profile: [
125
+ "id",
126
+ "channel_url",
127
+ "title",
128
+ "handle",
129
+ "email",
130
+ "joined_at",
131
+ "avatar_url",
132
+ "description",
133
+ "country",
134
+ "subscriber_count",
135
+ "view_count",
136
+ "video_count",
137
+ ],
138
+ },
139
+ primaryKey: { profile: ["id"] },
140
+ lossy: [
141
+ "Browser-visible profile values map to the retained legacy fields; values absent from the signed-in page remain null.",
142
+ ],
143
+ provenance: youtubeProvenance,
144
+ project(records, options) {
145
+ const guard = requireStreams("youtube.profile", options, ["profile"]);
146
+ if (guard)
147
+ return guard;
148
+ const profile = byStream(records, "profile")[0];
149
+ if (!profile)
150
+ return { ok: true, payload: {} };
151
+ const channelId = youtubeRequiredString(profile.id);
152
+ if (!channelId)
153
+ return {
154
+ ok: false,
155
+ error: {
156
+ kind: "invalid_value",
157
+ scope: "youtube.profile",
158
+ reason: "YouTube profile is missing its declared id",
159
+ },
160
+ };
161
+ return youtubeLegacyProjection("youtube.profile", 0, [], {
162
+ email: youtubeStringOrNull(profile.email),
163
+ channelUrl: youtubeStringOrNull(profile.channel_url),
164
+ handle: youtubeStringOrNull(profile.handle),
165
+ joinedDate: youtubeStringOrNull(profile.joined_at),
166
+ channelTitle: youtubeStringOrNull(profile.title),
167
+ channelId,
168
+ avatarUrl: youtubeStringOrNull(profile.avatar_url),
169
+ description: youtubeStringOrNull(profile.description),
170
+ country: youtubeStringOrNull(profile.country),
171
+ subscriberCount: typeof profile.subscriber_count === "number"
172
+ ? profile.subscriber_count
173
+ : null,
174
+ viewCount: typeof profile.view_count === "number" ? profile.view_count : null,
175
+ videoCount: typeof profile.video_count === "number" ? profile.video_count : null,
176
+ });
177
+ },
178
+ };
179
+ const youtubeSubscriptions = {
180
+ scope: "youtube.subscriptions",
181
+ pdppSource: youtubeProfile.pdppSource,
182
+ pdppStreams: ["subscriptions"],
183
+ legacySchemaPath: "youtube.subscriptions.json",
184
+ fieldsRead: {
185
+ subscriptions: [
186
+ "channel_id",
187
+ "channel_title",
188
+ "channel_url",
189
+ "handle",
190
+ "avatar_url",
191
+ "subscriber_count",
192
+ "description",
193
+ "is_verified",
194
+ "notifications",
195
+ ],
196
+ },
197
+ primaryKey: { subscriptions: ["id"] },
198
+ lossy: [
199
+ "Rows without legacy-required channelTitle/channelUrl or required notification booleans are skipped and reported.",
200
+ ],
201
+ provenance: youtubeProvenance,
202
+ project(records, options) {
203
+ const guard = requireStreams("youtube.subscriptions", options, [
204
+ "subscriptions",
205
+ ]);
206
+ if (guard)
207
+ return guard;
208
+ let skipped = 0;
209
+ const missing = new Set();
210
+ const subscriptions = byStream(records, "subscriptions").flatMap((row) => {
211
+ const channelTitle = youtubeRequiredString(row.channel_title);
212
+ const channelUrl = youtubeRequiredString(row.channel_url);
213
+ if (!channelTitle ||
214
+ !channelUrl ||
215
+ typeof row.is_verified !== "boolean" ||
216
+ typeof row.notifications !== "boolean") {
217
+ skipped++;
218
+ if (!channelTitle)
219
+ missing.add("subscriptions.channel_title");
220
+ if (!channelUrl)
221
+ missing.add("subscriptions.channel_url");
222
+ if (typeof row.is_verified !== "boolean")
223
+ missing.add("subscriptions.is_verified");
224
+ if (typeof row.notifications !== "boolean")
225
+ missing.add("subscriptions.notifications");
226
+ return [];
227
+ }
228
+ return [
229
+ {
230
+ channelTitle,
231
+ channelUrl,
232
+ handle: youtubeStringOrNull(row.handle),
233
+ channelId: youtubeStringOrNull(row.channel_id),
234
+ avatarUrl: youtubeStringOrNull(row.avatar_url),
235
+ subscriberCountText: null,
236
+ subscriberCount: typeof row.subscriber_count === "number"
237
+ ? row.subscriber_count
238
+ : null,
239
+ description: youtubeStringOrNull(row.description),
240
+ isVerified: row.is_verified,
241
+ isBellNotification: row.notifications,
242
+ },
243
+ ];
244
+ });
245
+ return youtubeLegacyProjection("youtube.subscriptions", skipped, [...missing], { subscriptions });
246
+ },
247
+ };
248
+ const youtubePlaylists = {
249
+ scope: "youtube.playlists",
250
+ pdppSource: youtubeProfile.pdppSource,
251
+ pdppStreams: ["playlists"],
252
+ legacySchemaPath: "youtube.playlists.json",
253
+ fieldsRead: {
254
+ playlists: [
255
+ "id",
256
+ "url",
257
+ "title",
258
+ "owner",
259
+ "owner_url",
260
+ "visibility",
261
+ "video_count",
262
+ "view_count",
263
+ ],
264
+ },
265
+ primaryKey: { playlists: ["id"] },
266
+ lossy: [
267
+ "Rows without a legacy-required URL are skipped and reported. Browser collection is a bounded snapshot.",
268
+ ],
269
+ provenance: youtubeProvenance,
270
+ project(records, options) {
271
+ const guard = requireStreams("youtube.playlists", options, ["playlists"]);
272
+ if (guard)
273
+ return guard;
274
+ let skipped = 0;
275
+ const playlists = byStream(records, "playlists").flatMap((row) => {
276
+ const playlistId = youtubeRequiredString(row.id);
277
+ const url = youtubeRequiredString(row.url);
278
+ if (!playlistId || !url) {
279
+ skipped++;
280
+ return [];
281
+ }
282
+ const privacy = typeof row.visibility === "string" &&
283
+ ["Public", "Private", "Unlisted"].includes(row.visibility)
284
+ ? row.visibility
285
+ : null;
286
+ return [
287
+ {
288
+ playlistId,
289
+ url,
290
+ title: youtubeStringOrNull(row.title),
291
+ owner: youtubeStringOrNull(row.owner),
292
+ ownerUrl: youtubeStringOrNull(row.owner_url),
293
+ privacy,
294
+ videoCount: typeof row.video_count === "number" ? row.video_count : null,
295
+ views: typeof row.view_count === "number" ? row.view_count : null,
296
+ },
297
+ ];
298
+ });
299
+ return youtubeLegacyProjection("youtube.playlists", skipped, skipped ? ["playlists.id", "playlists.url"] : [], { playlists });
300
+ },
301
+ };
302
+ function youtubeVideo(row) {
303
+ const videoUrl = youtubeRequiredString(row.video_url);
304
+ const videoTitle = youtubeRequiredString(row.video_title);
305
+ if (!videoUrl || !videoTitle)
306
+ return undefined;
307
+ const duration = typeof row.duration_seconds === "number" ? row.duration_seconds : null;
308
+ const durationText = duration === null
309
+ ? null
310
+ : duration >= 3600
311
+ ? `${Math.floor(duration / 3600)}:${String(Math.floor((duration % 3600) / 60)).padStart(2, "0")}:${String(duration % 60).padStart(2, "0")}`
312
+ : `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, "0")}`;
313
+ return {
314
+ videoId: youtubeStringOrNull(row.video_id),
315
+ videoUrl,
316
+ videoTitle,
317
+ channelTitle: youtubeStringOrNull(row.channel_title),
318
+ channelUrl: youtubeStringOrNull(row.channel_url),
319
+ durationText,
320
+ thumbnailUrl: youtubeStringOrNull(row.thumbnail_url),
321
+ };
322
+ }
323
+ const youtubePlaylistItems = {
324
+ scope: "youtube.playlistItems",
325
+ pdppSource: youtubeProfile.pdppSource,
326
+ pdppStreams: ["playlist_items"],
327
+ legacySchemaPath: "youtube.playlistItems.json",
328
+ fieldsRead: {
329
+ playlist_items: [
330
+ "playlist_id",
331
+ "video_id",
332
+ "video_url",
333
+ "video_title",
334
+ "channel_title",
335
+ "channel_url",
336
+ "duration_seconds",
337
+ "thumbnail_url",
338
+ ],
339
+ },
340
+ primaryKey: { playlist_items: ["id"] },
341
+ lossy: [
342
+ "Playlist headers are unavailable on item rows. Records missing required videoTitle/videoUrl or playlist_id are skipped and reported. duration_seconds is formatted as m:ss.",
343
+ ],
344
+ provenance: youtubeProvenance,
345
+ project(records, options) {
346
+ const guard = requireStreams("youtube.playlistItems", options, [
347
+ "playlist_items",
348
+ ]);
349
+ if (guard)
350
+ return guard;
351
+ const grouped = new Map();
352
+ let skipped = 0;
353
+ for (const row of byStream(records, "playlist_items")) {
354
+ const playlistId = youtubeRequiredString(row.playlist_id);
355
+ const video = youtubeVideo(row);
356
+ if (!playlistId || !video) {
357
+ skipped++;
358
+ continue;
359
+ }
360
+ const group = grouped.get(playlistId) ?? { playlistId, items: [] };
361
+ group.items.push(video);
362
+ grouped.set(playlistId, group);
363
+ }
364
+ return youtubeLegacyProjection("youtube.playlistItems", skipped, skipped
365
+ ? [
366
+ "playlist_items.playlist_id",
367
+ "playlist_items.video_url",
368
+ "playlist_items.video_title",
369
+ ]
370
+ : [], {
371
+ playlists: [...grouped.values()].map((group) => ({
372
+ ...group,
373
+ playlistTitle: null,
374
+ playlistUrl: null,
375
+ })),
376
+ });
377
+ },
378
+ };
379
+ function youtubeNamedVideos(scope, stream, key) {
380
+ return {
381
+ scope,
382
+ pdppSource: youtubeProfile.pdppSource,
383
+ pdppStreams: [stream],
384
+ legacySchemaPath: `${scope}.json`,
385
+ fieldsRead: {
386
+ [stream]: [
387
+ "video_id",
388
+ "video_url",
389
+ "video_title",
390
+ "channel_title",
391
+ "channel_url",
392
+ "duration_seconds",
393
+ "thumbnail_url",
394
+ ],
395
+ },
396
+ primaryKey: { [stream]: ["id"] },
397
+ lossy: [
398
+ "Legacy requires videoTitle and videoUrl; incomplete browser rows are skipped and reported.",
399
+ ],
400
+ provenance: youtubeProvenance,
401
+ project(records, options) {
402
+ const guard = requireStreams(scope, options, [stream]);
403
+ if (guard)
404
+ return guard;
405
+ let skipped = 0;
406
+ const videos = byStream(records, stream).flatMap((row) => {
407
+ const video = youtubeVideo(row);
408
+ if (!video) {
409
+ skipped++;
410
+ return [];
411
+ }
412
+ return [video];
413
+ });
414
+ return youtubeLegacyProjection(scope, skipped, skipped ? [`${stream}.video_url`, `${stream}.video_title`] : [], { [key]: videos });
415
+ },
416
+ };
417
+ }
418
+ const youtubeLikes = youtubeNamedVideos("youtube.likes", "likes", "likedVideos");
419
+ const youtubeWatchLater = youtubeNamedVideos("youtube.watchLater", "watch_later", "watchLater");
420
+ const youtubeHistory = {
421
+ scope: "youtube.history",
422
+ pdppSource: youtubeProfile.pdppSource,
423
+ pdppStreams: ["watch_history"],
424
+ legacySchemaPath: "youtube.history.json",
425
+ fieldsRead: {
426
+ watch_history: [
427
+ "position",
428
+ "watched_date",
429
+ "video_id",
430
+ "video_url",
431
+ "video_title",
432
+ "channel_title",
433
+ "channel_url",
434
+ "view_count",
435
+ "description",
436
+ ],
437
+ },
438
+ primaryKey: { watch_history: ["id"] },
439
+ lossy: [
440
+ "Preserves page order by position and caps output at 50 entries. watchedAtText carries watched_date at day precision only; no timestamp is inferred. Integer view_count is rendered as '<count> views'; absent view_count remains null. Rows without required videoUrl are skipped and reported. Missing positions fail because page order cannot be preserved.",
441
+ ],
442
+ provenance: youtubeProvenance,
443
+ project(records, options) {
444
+ const guard = requireStreams("youtube.history", options, ["watch_history"]);
445
+ if (guard)
446
+ return guard;
447
+ let skipped = 0;
448
+ const sourceRows = byStream(records, "watch_history");
449
+ if (sourceRows.some((row) => typeof row.position !== "number" || !Number.isInteger(row.position))) {
450
+ return {
451
+ ok: false,
452
+ error: {
453
+ kind: "invalid_value",
454
+ scope: "youtube.history",
455
+ reason: "YouTube browser history is missing an integer position; page order cannot be preserved",
456
+ },
457
+ };
458
+ }
459
+ const rows = sourceRows
460
+ .flatMap((row) => {
461
+ const videoUrl = youtubeRequiredString(row.video_url);
462
+ if (!videoUrl) {
463
+ skipped++;
464
+ return [];
465
+ }
466
+ const viewCount = typeof row.view_count === "number" ? row.view_count : null;
467
+ return [
468
+ {
469
+ watchedAtText: youtubeStringOrNull(row.watched_date),
470
+ videoId: youtubeStringOrNull(row.video_id),
471
+ videoUrl,
472
+ videoTitle: youtubeStringOrNull(row.video_title),
473
+ channelTitle: youtubeStringOrNull(row.channel_title),
474
+ views: viewCount === null ? null : `${viewCount} views`,
475
+ description: youtubeStringOrNull(row.description),
476
+ _position: row.position,
477
+ },
478
+ ];
479
+ })
480
+ .sort((a, b) => a._position - b._position);
481
+ const history = rows
482
+ .slice(0, 50)
483
+ .map(({ _position: _ignored, ...row }) => row);
484
+ return youtubeLegacyProjection("youtube.history", skipped, skipped ? ["watch_history.video_url"] : [], { timeWindow: "top 50 most recent items", history });
485
+ },
486
+ };
487
+ function isNonEmptyString(value) {
488
+ return typeof value === "string" && value.trim().length > 0;
489
+ }
490
+ function isOptionalString(value) {
491
+ return value === undefined || value === null || typeof value === "string";
492
+ }
493
+ /**
494
+ * Validates that a string is a valid ISO 8601 date-time. Rejects invalid
495
+ * formats so only valid date-times pass through to the payload (F5).
496
+ */
497
+ function isValidIso8601DateTime(value) {
498
+ if (typeof value !== "string")
499
+ return false;
500
+ const date = new Date(value);
501
+ // The string is valid if it parses to a valid date (not NaN).
502
+ return !isNaN(date.getTime());
503
+ }
504
+ /**
505
+ * Reformats an ISO-8601 date/date-time string to amazon-playwright.js's
506
+ * captured legacy shape, `"March 5, 2024"` (verified: the connector's own
507
+ * regex only ever matches `MonthName D, YYYY`,
508
+ * connectors/amazon/amazon-playwright.js:184-187 at the pin). Returns `''`
509
+ * (the connector's own no-match fallback) for anything that does not parse
510
+ * as a date, rather than passing through a shape the legacy schema does not
511
+ * expect.
512
+ */
513
+ const MONTH_NAMES = [
514
+ "January",
515
+ "February",
516
+ "March",
517
+ "April",
518
+ "May",
519
+ "June",
520
+ "July",
521
+ "August",
522
+ "September",
523
+ "October",
524
+ "November",
525
+ "December",
526
+ ];
527
+ function toLegacyLongDate(value) {
528
+ if (typeof value !== "string") {
529
+ return "";
530
+ }
531
+ const d = new Date(value);
532
+ if (Number.isNaN(d.getTime())) {
533
+ return "";
534
+ }
535
+ return `${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
536
+ }
537
+ /**
538
+ * Reformats start/end date strings to linkedin-playwright.js's captured
539
+ * legacy `dates` shape for experience and education, e.g.
540
+ * `"1/2024 - Present"` (verified: `extractTimePeriod`,
541
+ * connectors/linkedin/linkedin-playwright.js:129-145 — `(month + '/' ) +
542
+ * year` per side, joined by `' - '`, `'Present'` for a missing end, then
543
+ * `.trim()`-ed so a missing start reads `"- Present"` / `"- 3/2020"`). The
544
+ * signed LinkedIn 0.3.1 declaration uses partial dates (`YYYY` or `YYYY-MM`),
545
+ * so year-only values keep their source precision. Full ISO values keep the
546
+ * earlier month/year behavior.
547
+ */
548
+ function toLegacyTimePeriod(start, end) {
549
+ const part = (value) => {
550
+ if (typeof value !== "string") {
551
+ return "";
552
+ }
553
+ const yearOnly = value.match(/^(\d{4})$/);
554
+ if (yearOnly) {
555
+ return yearOnly[1];
556
+ }
557
+ const yearMonth = value.match(/^(\d{4})-(\d{2})$/);
558
+ if (yearMonth) {
559
+ return `${Number(yearMonth[2])}/${yearMonth[1]}`;
560
+ }
561
+ const d = new Date(value);
562
+ if (Number.isNaN(d.getTime())) {
563
+ return "";
564
+ }
565
+ return `${d.getUTCMonth() + 1}/${d.getUTCFullYear()}`;
566
+ };
567
+ const startPart = part(start);
568
+ const endPart = typeof end === "string" ? part(end) : "";
569
+ return `${startPart} - ${endPart || "Present"}`.trim();
570
+ }
571
+ // ---------------------------------------------------------------------------
572
+ // meta (instagram)
573
+ // declarations/meta.collection-profile.json, streams "profile" and "posts".
574
+ // connector_id (verified byte-identical to the vendored declaration):
575
+ // https://registry.pdpp.dev/connectors/meta
576
+ // ---------------------------------------------------------------------------
577
+ const instagramProfile = {
578
+ scope: "instagram.profile",
579
+ pdppSource: "https://registry.pdpp.dev/connectors/meta",
580
+ pdppStreams: ["profile"],
581
+ legacySchemaPath: "instagram.profile.json",
582
+ fieldsRead: {
583
+ profile: [
584
+ "username",
585
+ "full_name",
586
+ "bio",
587
+ "profile_pic_url",
588
+ "external_url",
589
+ "follower_count",
590
+ "following_count",
591
+ "is_private",
592
+ "is_verified",
593
+ "is_business",
594
+ ],
595
+ },
596
+ primaryKey: { profile: ["id"] },
597
+ lossy: [
598
+ "media_count (no PDPP equivalent; post_count is a different concept — Instagram's media_count includes non-post media types the posts stream does not cover)",
599
+ ],
600
+ provenance: [
601
+ {
602
+ kind: "oci",
603
+ ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
604
+ digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
605
+ path: "declarations/meta.collection-profile.json#streams[name=profile]",
606
+ },
607
+ ],
608
+ project(records, options) {
609
+ const guard = requireStreams("instagram.profile", options, ["profile"]);
610
+ if (guard) {
611
+ return guard;
612
+ }
613
+ const [p] = byStream(records, "profile");
614
+ if (!p) {
615
+ return {
616
+ ok: false,
617
+ error: { kind: "empty_singleton", scope: "instagram.profile" },
618
+ };
619
+ }
620
+ const username = p.username;
621
+ if (typeof username !== "string") {
622
+ return {
623
+ ok: false,
624
+ error: {
625
+ kind: "invalid_value",
626
+ scope: "instagram.profile",
627
+ reason: "Meta profile is missing username",
628
+ },
629
+ };
630
+ }
631
+ const fullName = p.full_name == null
632
+ ? ""
633
+ : typeof p.full_name === "string"
634
+ ? p.full_name
635
+ : undefined;
636
+ if (fullName === undefined) {
637
+ return {
638
+ ok: false,
639
+ error: {
640
+ kind: "invalid_value",
641
+ scope: "instagram.profile",
642
+ reason: "Meta profile full_name is not a string",
643
+ },
644
+ };
645
+ }
646
+ const payload = {
647
+ username,
648
+ full_name: fullName,
649
+ };
650
+ if (typeof p.bio === "string")
651
+ payload.bio = p.bio;
652
+ if (typeof p.profile_pic_url === "string") {
653
+ payload.profile_pic_url = p.profile_pic_url;
654
+ }
655
+ if (typeof p.external_url === "string") {
656
+ payload.external_url = p.external_url;
657
+ }
658
+ if (typeof p.follower_count === "number") {
659
+ payload.follower_count = p.follower_count;
660
+ }
661
+ if (typeof p.following_count === "number") {
662
+ payload.following_count = p.following_count;
663
+ }
664
+ if (typeof p.is_private === "boolean")
665
+ payload.is_private = p.is_private;
666
+ if (typeof p.is_verified === "boolean")
667
+ payload.is_verified = p.is_verified;
668
+ if (typeof p.is_business === "boolean")
669
+ payload.is_business = p.is_business;
670
+ return {
671
+ ok: true,
672
+ payload,
673
+ };
674
+ },
675
+ };
676
+ // ---------------------------------------------------------------------------
677
+ // instagram.posts
678
+ // declarations/meta.collection-profile.json#streams[name=posts]
679
+ // connector_id: https://registry.pdpp.dev/connectors/meta
680
+ // ---------------------------------------------------------------------------
681
+ const instagramPosts = {
682
+ scope: "instagram.posts",
683
+ pdppSource: "https://registry.pdpp.dev/connectors/meta",
684
+ pdppStreams: ["posts", "post_likes"],
685
+ legacySchemaPath: "instagram.posts.json",
686
+ fieldsRead: {
687
+ posts: ["id", "media_url", "caption", "like_count", "taken_at"],
688
+ post_likes: ["post_id", "user_id", "username"],
689
+ },
690
+ primaryKey: { posts: ["id"], post_likes: ["post_id", "liker_ordinal"] },
691
+ lossy: [
692
+ "nullable media_url, caption, and like_count use the retained collector's empty-string and zero sentinels; every source post is retained",
693
+ "media_url is only available for images and the first image in a carousel; video posts and later carousel images are absent from the artifact's parser output",
694
+ "who_liked.profile_pic_url is omitted because the post_likes stream does not declare it",
695
+ "who_liked.pk and who_liked.id both use post_likes.user_id because the source exposes one liker identity field",
696
+ ],
697
+ provenance: [
698
+ {
699
+ kind: "oci",
700
+ ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
701
+ digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
702
+ path: "declarations/meta.collection-profile.json#streams[name=posts,post_likes]",
703
+ },
704
+ ],
705
+ project(records, options) {
706
+ const guard = requireStreams("instagram.posts", options, [
707
+ "posts",
708
+ "post_likes",
709
+ ]);
710
+ if (guard) {
711
+ return guard;
712
+ }
713
+ const sourcePosts = byStream(records, "posts");
714
+ const likesByPost = new Map();
715
+ for (const liker of byStream(records, "post_likes")) {
716
+ if (typeof liker.post_id !== "string" ||
717
+ typeof liker.user_id !== "string" ||
718
+ typeof liker.username !== "string") {
719
+ continue;
720
+ }
721
+ const whoLiked = likesByPost.get(liker.post_id) ?? [];
722
+ whoLiked.push({
723
+ pk: liker.user_id,
724
+ username: liker.username,
725
+ id: liker.user_id,
726
+ });
727
+ likesByPost.set(liker.post_id, whoLiked);
728
+ }
729
+ const posts = [];
730
+ for (const sourcePost of sourcePosts) {
731
+ const projectedPost = {
732
+ img_url: orEmptyString(sourcePost.media_url),
733
+ caption: orEmptyString(sourcePost.caption),
734
+ num_of_likes: orZero(sourcePost.like_count),
735
+ who_liked: typeof sourcePost.id === "string"
736
+ ? (likesByPost.get(sourcePost.id) ?? [])
737
+ : [],
738
+ };
739
+ if (typeof sourcePost.taken_at === "string") {
740
+ projectedPost.taken_at = sourcePost.taken_at;
741
+ }
742
+ posts.push(projectedPost);
743
+ }
744
+ return {
745
+ ok: true,
746
+ payload: { posts },
747
+ };
748
+ },
749
+ };
750
+ // ---------------------------------------------------------------------------
751
+ // instagram.following and instagram.ads
752
+ // declarations/meta.collection-profile.json#streams[name=following,ads]
753
+ // ---------------------------------------------------------------------------
754
+ const instagramFollowing = {
755
+ scope: "instagram.following",
756
+ pdppSource: "https://registry.pdpp.dev/connectors/meta",
757
+ pdppStreams: ["following"],
758
+ legacySchemaPath: "instagram.following.json",
759
+ fieldsRead: {
760
+ following: [
761
+ "id",
762
+ "username",
763
+ "full_name",
764
+ "is_private",
765
+ "is_verified",
766
+ "profile_pic_url",
767
+ ],
768
+ },
769
+ primaryKey: { following: ["id"] },
770
+ lossy: [
771
+ "rows without a non-empty string username are skipped because the legacy account schema requires username",
772
+ ],
773
+ provenance: [
774
+ {
775
+ kind: "oci",
776
+ ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
777
+ digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
778
+ path: "declarations/meta.collection-profile.json#streams[name=following]",
779
+ },
780
+ ],
781
+ project(records, options) {
782
+ const guard = requireStreams("instagram.following", options, ["following"]);
783
+ if (guard)
784
+ return guard;
785
+ const accounts = byStream(records, "following").flatMap((r) => {
786
+ if (typeof r.username !== "string" || !r.username)
787
+ return [];
788
+ const account = {
789
+ username: r.username,
790
+ full_name: typeof r.full_name === "string" ? r.full_name : "",
791
+ is_private: r.is_private === true,
792
+ is_verified: r.is_verified === true,
793
+ profile_pic_url: typeof r.profile_pic_url === "string" && r.profile_pic_url
794
+ ? r.profile_pic_url
795
+ : null,
796
+ };
797
+ if (typeof r.id === "string")
798
+ account.pk = r.id;
799
+ return [account];
800
+ });
801
+ return { ok: true, payload: { accounts, total: accounts.length } };
802
+ },
803
+ };
804
+ const instagramAds = {
805
+ scope: "instagram.ads",
806
+ pdppSource: "https://registry.pdpp.dev/connectors/meta",
807
+ pdppStreams: ["ads"],
808
+ legacySchemaPath: "instagram.ads.json",
809
+ fieldsRead: { ads: ["kind", "name", "description"] },
810
+ primaryKey: { ads: ["id"] },
811
+ lossy: [
812
+ "ads rows with a null description omit the optional legacy category description",
813
+ "malformed ads rows without a string name or one of the declaration's three kinds are skipped",
814
+ ],
815
+ provenance: [
816
+ {
817
+ kind: "oci",
818
+ ref: "ghcr.io/pdp-connect/connector/meta:0.4.0",
819
+ digest: "sha256:9805c13ab4ad45caf904b4ce834764e701e426f5c3d2f3697fbab2705d1636ea",
820
+ path: "declarations/meta.collection-profile.json#streams[name=ads]",
821
+ },
822
+ ],
823
+ project(records, options) {
824
+ const guard = requireStreams("instagram.ads", options, ["ads"]);
825
+ if (guard)
826
+ return guard;
827
+ const advertisers = [];
828
+ const ad_topics = [];
829
+ const categories = [];
830
+ for (const row of byStream(records, "ads")) {
831
+ if (typeof row.name !== "string")
832
+ continue;
833
+ if (row.kind === "advertiser")
834
+ advertisers.push({ name: row.name });
835
+ if (row.kind === "ad_topic")
836
+ ad_topics.push({ name: row.name });
837
+ if (row.kind === "ad_category") {
838
+ categories.push({
839
+ name: row.name,
840
+ ...(typeof row.description === "string"
841
+ ? { description: row.description }
842
+ : {}),
843
+ });
844
+ }
845
+ }
846
+ return { ok: true, payload: { advertisers, ad_topics, categories } };
847
+ },
848
+ };
849
+ // ---------------------------------------------------------------------------
850
+ // github
851
+ // declarations/github.collection-profile.json
852
+ // connector_id: https://registry.pdpp.dev/connectors/github
853
+ // ---------------------------------------------------------------------------
854
+ const githubRepositories = {
855
+ scope: "github.repositories",
856
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
857
+ pdppStreams: ["repositories"],
858
+ legacySchemaPath: "github.repositories.json",
859
+ fieldsRead: {
860
+ repositories: [
861
+ "name",
862
+ "html_url",
863
+ "description",
864
+ "language",
865
+ "stargazers_count",
866
+ "forks_count",
867
+ "private",
868
+ "topics",
869
+ "updated_at",
870
+ ],
871
+ },
872
+ primaryKey: { repositories: ["id"] },
873
+ lossy: [
874
+ "description, language, updatedAt: PDPP-side null projects as legacy's own '' / null default, not a fabricated value, but omission still differs from a populated legacy record",
875
+ ],
876
+ provenance: [
877
+ {
878
+ kind: "oci",
879
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
880
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
881
+ path: "declarations/github.collection-profile.json#streams[name=repositories]",
882
+ },
883
+ ],
884
+ project(records, options) {
885
+ const guard = requireStreams("github.repositories", options, [
886
+ "repositories",
887
+ ]);
888
+ if (guard) {
889
+ return guard;
890
+ }
891
+ const items = byStream(records, "repositories");
892
+ // Legacy requires name, url per item; allows description, language,
893
+ // stars, forks, visibility, topics, updatedAt. `url` is `html_url` on
894
+ // the repositories stream (a real GitHub URL field, not a template).
895
+ // `visibility` is the literal text GitHub's own repo-list DOM renders,
896
+ // capitalized "Public"/"Private" (github-playwright.js:416,
897
+ // `row.querySelector('span.Label')?.textContent || 'Public'`) — derived
898
+ // here from PDPP's boolean `private` field with the matching casing,
899
+ // not lowercase.
900
+ return {
901
+ ok: true,
902
+ payload: {
903
+ repositories: items.map((r) => ({
904
+ name: orEmptyString(r.name),
905
+ url: orEmptyString(r.html_url),
906
+ description: orEmptyString(r.description),
907
+ language: orEmptyString(r.language),
908
+ stars: orZero(r.stargazers_count),
909
+ forks: orZero(r.forks_count),
910
+ visibility: r.private === true ? "Private" : "Public",
911
+ topics: Array.isArray(r.topics) ? r.topics : [],
912
+ updatedAt: typeof r.updated_at === "string" ? r.updated_at : null,
913
+ })),
914
+ },
915
+ };
916
+ },
917
+ };
918
+ const githubStarred = {
919
+ scope: "github.starred",
920
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
921
+ pdppStreams: ["starred"],
922
+ legacySchemaPath: "github.starred.json",
923
+ fieldsRead: {
924
+ starred: [
925
+ "full_name",
926
+ "html_url",
927
+ "description",
928
+ "language",
929
+ "stargazers_count",
930
+ ],
931
+ },
932
+ primaryKey: { starred: ["id"] },
933
+ lossy: [
934
+ "updatedAt changes meaning: legacy is the repo's own last-updated time from the stars page (github-playwright.js:415, relative-time datetime); PDPP's starred stream only carries starred_at, the time the user starred it. Field is emitted as null (no PDPP source for repo update time), not omitted.",
935
+ "description, language: PDPP-side null projects as ''",
936
+ ],
937
+ provenance: [
938
+ {
939
+ kind: "oci",
940
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
941
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
942
+ path: "declarations/github.collection-profile.json#streams[name=starred]",
943
+ },
944
+ ],
945
+ project(records, options) {
946
+ const guard = requireStreams("github.starred", options, ["starred"]);
947
+ if (guard) {
948
+ return guard;
949
+ }
950
+ const items = byStream(records, "starred");
951
+ return {
952
+ ok: true,
953
+ payload: {
954
+ starred: items.map((r) => ({
955
+ fullName: orEmptyString(r.full_name),
956
+ url: orEmptyString(r.html_url),
957
+ description: orEmptyString(r.description),
958
+ language: orEmptyString(r.language),
959
+ stars: orZero(r.stargazers_count),
960
+ updatedAt: null,
961
+ })),
962
+ },
963
+ };
964
+ },
965
+ };
966
+ // github.history can be projected from authored pull_requests plus authored
967
+ // issues. The signed issues stream also contains assigned issues, so it is
968
+ // filtered against the signed user.login singleton before projection.
969
+ const githubHistory = {
970
+ scope: "github.history",
971
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
972
+ pdppStreams: ["user", "issues", "pull_requests"],
973
+ legacySchemaPath: "github.history.json",
974
+ fieldsRead: {
975
+ user: ["login"],
976
+ issues: [
977
+ "id",
978
+ "number",
979
+ "title",
980
+ "body",
981
+ "state",
982
+ "user_login",
983
+ "labels",
984
+ "repository_full_name",
985
+ "html_url",
986
+ "comments",
987
+ "reactions_total_count",
988
+ "created_at",
989
+ "updated_at",
990
+ "closed_at",
991
+ "is_pull_request",
992
+ ],
993
+ pull_requests: [
994
+ "id",
995
+ "number",
996
+ "title",
997
+ "body",
998
+ "state",
999
+ "labels",
1000
+ "repository_full_name",
1001
+ "html_url",
1002
+ "comments",
1003
+ "reactions_total_count",
1004
+ "created_at",
1005
+ "updated_at",
1006
+ "closed_at",
1007
+ "merged_at",
1008
+ "draft",
1009
+ ],
1010
+ },
1011
+ primaryKey: { user: ["id"], issues: ["id"], pull_requests: ["id"] },
1012
+ lossy: [
1013
+ "legacy IDs used gh-issue-/gh-pr- prefixes; projection uses the signed PDPP record ID directly",
1014
+ "body is truncated to the legacy connector's 500-character limit",
1015
+ "repoUrl is omitted because no repository URL field is declared on issue or pull request streams",
1016
+ "fetchedAt is set at projection time because the legacy schema requires it and PDPP history records have no fetch timestamp",
1017
+ "items with missing required legacy repo names or malformed declared fields are skipped",
1018
+ ],
1019
+ provenance: [
1020
+ {
1021
+ kind: "oci",
1022
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
1023
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
1024
+ path: "declarations/github.collection-profile.json#streams[name=user,issues,pull_requests]",
1025
+ },
1026
+ ],
1027
+ project(records, options) {
1028
+ const streams = ["user", "issues", "pull_requests"];
1029
+ const guard = requireStreams("github.history", options, streams);
1030
+ if (guard)
1031
+ return guard;
1032
+ const login = byStream(records, "user")[0]?.login;
1033
+ const sourceIssues = byStream(records, "issues");
1034
+ if (typeof login !== "string" && sourceIssues.length > 0) {
1035
+ return {
1036
+ ok: false,
1037
+ error: {
1038
+ kind: "invalid_value",
1039
+ scope: "github.history",
1040
+ reason: "Signed user stream has no login for authored-issue filtering",
1041
+ },
1042
+ };
1043
+ }
1044
+ const projectItem = (r, type) => {
1045
+ if (typeof r.id !== "string" ||
1046
+ typeof r.repository_full_name !== "string")
1047
+ return null;
1048
+ const item = {
1049
+ id: r.id,
1050
+ type,
1051
+ repo: r.repository_full_name,
1052
+ comments: typeof r.comments === "number" ? r.comments : 0,
1053
+ reactionsTotal: typeof r.reactions_total_count === "number"
1054
+ ? r.reactions_total_count
1055
+ : 0,
1056
+ isDraft: type === "pr" &&
1057
+ r.draft === true &&
1058
+ r.state === "open" &&
1059
+ r.merged_at == null,
1060
+ };
1061
+ if (typeof r.number === "number")
1062
+ item.number = r.number;
1063
+ if (typeof r.title === "string" || r.title === null)
1064
+ item.title = r.title;
1065
+ if (typeof r.body === "string" || r.body === null) {
1066
+ item.body = typeof r.body === "string" ? r.body.slice(0, 500) : null;
1067
+ }
1068
+ if (typeof r.state === "string" || r.state === null)
1069
+ item.state = r.state;
1070
+ for (const [source, target] of [
1071
+ ["created_at", "createdAt"],
1072
+ ["updated_at", "updatedAt"],
1073
+ ["closed_at", "closedAt"],
1074
+ ["html_url", "url"],
1075
+ ]) {
1076
+ if (typeof r[source] === "string" || r[source] === null)
1077
+ item[target] = r[source];
1078
+ }
1079
+ if (type === "pr" &&
1080
+ (typeof r.merged_at === "string" || r.merged_at === null)) {
1081
+ item.mergedAt = r.merged_at;
1082
+ }
1083
+ item.labels = Array.isArray(r.labels)
1084
+ ? r.labels.filter((label) => typeof label === "string")
1085
+ : [];
1086
+ return item;
1087
+ };
1088
+ const pullRequests = byStream(records, "pull_requests").flatMap((r) => {
1089
+ const item = projectItem(r, "pr");
1090
+ return item ? [item] : [];
1091
+ });
1092
+ const issues = sourceIssues.flatMap((r) => {
1093
+ if (typeof r.is_pull_request !== "boolean" ||
1094
+ r.is_pull_request ||
1095
+ r.user_login !== login) {
1096
+ return [];
1097
+ }
1098
+ const item = projectItem(r, "issue");
1099
+ return item ? [item] : [];
1100
+ });
1101
+ const authoredIssues = sourceIssues.filter((r) => typeof login === "string" &&
1102
+ typeof r.is_pull_request === "boolean" &&
1103
+ !r.is_pull_request &&
1104
+ r.user_login === login);
1105
+ if (pullRequests.length !== byStream(records, "pull_requests").length ||
1106
+ issues.length !== authoredIssues.length) {
1107
+ return {
1108
+ ok: false,
1109
+ error: {
1110
+ kind: "incomplete_scope",
1111
+ scope: "github.history",
1112
+ reason: "Cannot preserve every authored history row: required id or repository_full_name is missing",
1113
+ },
1114
+ };
1115
+ }
1116
+ return {
1117
+ ok: true,
1118
+ payload: { pullRequests, issues, fetchedAt: projectionTime(options) },
1119
+ };
1120
+ },
1121
+ };
1122
+ const githubProfile = {
1123
+ scope: "github.profile",
1124
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
1125
+ pdppStreams: ["user", "user_stats", "pinned_repositories", "organizations"],
1126
+ legacySchemaPath: "github.profile.json",
1127
+ fieldsRead: {
1128
+ user: [
1129
+ "login",
1130
+ "name",
1131
+ "bio",
1132
+ "company",
1133
+ "location",
1134
+ "blog",
1135
+ "avatar_url",
1136
+ "achievements",
1137
+ ],
1138
+ user_stats: ["observed_on", "followers", "following", "public_repos"],
1139
+ pinned_repositories: [
1140
+ "full_name",
1141
+ "html_url",
1142
+ "description",
1143
+ "languages",
1144
+ "stargazers_count",
1145
+ ],
1146
+ organizations: ["login", "description", "avatar_url"],
1147
+ },
1148
+ primaryKey: {
1149
+ user: ["id"],
1150
+ user_stats: ["id"],
1151
+ pinned_repositories: ["id"],
1152
+ organizations: ["id"],
1153
+ },
1154
+ lossy: [
1155
+ "profileUrl is derived from the authenticated user's login using GitHub's canonical profile URL pattern",
1156
+ "organization label and URL are omitted because the organizations stream carries neither field; description is not substituted for label",
1157
+ "pinned repository description, language, URL, and stars are omitted when the source value is absent; languages is a list and legacy supports only one language, so the first collected language is used",
1158
+ "contributionsLastYear is omitted: daily contribution data may be an incremental or caller-limited window and cannot establish a complete rolling-year total",
1159
+ "follower, following, and repository counts use the newest observed_on stats record; no value is emitted if no stats record exists",
1160
+ ],
1161
+ provenance: [
1162
+ {
1163
+ kind: "oci",
1164
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
1165
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
1166
+ path: "declarations/github.collection-profile.json#streams[name=user,user_stats,pinned_repositories,organizations]",
1167
+ },
1168
+ ],
1169
+ project(records, options) {
1170
+ const streams = [
1171
+ "user",
1172
+ "user_stats",
1173
+ "pinned_repositories",
1174
+ "organizations",
1175
+ ];
1176
+ const guard = requireStreams("github.profile", options, streams);
1177
+ if (guard)
1178
+ return guard;
1179
+ const user = byStream(records, "user")[0];
1180
+ if (!user || typeof user.login !== "string") {
1181
+ return {
1182
+ ok: false,
1183
+ error: { kind: "empty_singleton", scope: "github.profile" },
1184
+ };
1185
+ }
1186
+ const payload = {
1187
+ username: user.login,
1188
+ profileUrl: `https://github.com/${user.login}`,
1189
+ };
1190
+ for (const [source, target] of [
1191
+ ["name", "fullName"],
1192
+ ["bio", "bio"],
1193
+ ["company", "company"],
1194
+ ["location", "location"],
1195
+ ["blog", "website"],
1196
+ ["avatar_url", "avatarUrl"],
1197
+ ]) {
1198
+ if (typeof user[source] === "string")
1199
+ payload[target] = user[source];
1200
+ }
1201
+ const stats = byStream(records, "user_stats")
1202
+ .filter((r) => typeof r.observed_on === "string")
1203
+ .sort((a, b) => String(b.observed_on).localeCompare(String(a.observed_on)))[0];
1204
+ if (stats) {
1205
+ for (const [source, target] of [
1206
+ ["followers", "followers"],
1207
+ ["following", "following"],
1208
+ ["public_repos", "repositoryCount"],
1209
+ ]) {
1210
+ if (typeof stats[source] === "number")
1211
+ payload[target] = stats[source];
1212
+ }
1213
+ }
1214
+ if (Array.isArray(user.achievements)) {
1215
+ payload.achievements = user.achievements.flatMap((value) => {
1216
+ if (!value || typeof value !== "object")
1217
+ return [];
1218
+ const item = value;
1219
+ if (typeof item.name !== "string")
1220
+ return [];
1221
+ return [
1222
+ {
1223
+ name: item.name,
1224
+ ...(typeof item.icon_url === "string" || item.icon_url === null
1225
+ ? { iconUrl: item.icon_url }
1226
+ : {}),
1227
+ },
1228
+ ];
1229
+ });
1230
+ }
1231
+ payload.pinnedRepositories = byStream(records, "pinned_repositories").flatMap((r) => {
1232
+ if (typeof r.full_name !== "string")
1233
+ return [];
1234
+ const item = { fullName: r.full_name };
1235
+ if (typeof r.html_url === "string" || r.html_url === null)
1236
+ item.url = r.html_url;
1237
+ if (typeof r.description === "string")
1238
+ item.description = r.description;
1239
+ if (Array.isArray(r.languages) && typeof r.languages[0] === "string")
1240
+ item.language = r.languages[0];
1241
+ if (typeof r.stargazers_count === "number")
1242
+ item.stars = r.stargazers_count;
1243
+ return [item];
1244
+ });
1245
+ payload.organizations = byStream(records, "organizations").flatMap((r) => {
1246
+ if (typeof r.login !== "string")
1247
+ return [];
1248
+ return [
1249
+ {
1250
+ login: r.login,
1251
+ ...(typeof r.avatar_url === "string" || r.avatar_url === null
1252
+ ? { avatarUrl: r.avatar_url }
1253
+ : {}),
1254
+ },
1255
+ ];
1256
+ });
1257
+ return { ok: true, payload };
1258
+ },
1259
+ };
1260
+ const githubEvents = {
1261
+ scope: "github.events",
1262
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
1263
+ pdppStreams: ["events"],
1264
+ legacySchemaPath: "github.events.json",
1265
+ fieldsRead: {
1266
+ events: ["id", "type", "created_at", "repository_full_name", "is_public"],
1267
+ },
1268
+ primaryKey: { events: ["id"] },
1269
+ lossy: [
1270
+ "the events API retains only roughly 90 days; the connector emits each collected public event as a legacy event",
1271
+ "legacy action, title, body, URL, branch, commit count, repository URL, and window description are omitted because events records do not declare those values",
1272
+ "fetchedAt is set at projection time because the stream has no collection timestamp",
1273
+ "malformed records missing legacy-required fields are skipped",
1274
+ ],
1275
+ provenance: [
1276
+ {
1277
+ kind: "oci",
1278
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
1279
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
1280
+ path: "declarations/github.collection-profile.json#streams[name=events]",
1281
+ },
1282
+ ],
1283
+ project(records, options) {
1284
+ const guard = requireStreams("github.events", options, ["events"]);
1285
+ if (guard)
1286
+ return guard;
1287
+ const events = byStream(records, "events").flatMap((r) => {
1288
+ if (typeof r.id !== "string" ||
1289
+ typeof r.type !== "string" ||
1290
+ typeof r.created_at !== "string" ||
1291
+ typeof r.repository_full_name !== "string" ||
1292
+ typeof r.is_public !== "boolean")
1293
+ return [];
1294
+ return [
1295
+ {
1296
+ id: r.id,
1297
+ type: r.type,
1298
+ createdAt: r.created_at,
1299
+ repo: r.repository_full_name,
1300
+ isPublic: r.is_public,
1301
+ },
1302
+ ];
1303
+ });
1304
+ return {
1305
+ ok: true,
1306
+ payload: { events, fetchedAt: projectionTime(options) },
1307
+ };
1308
+ },
1309
+ };
1310
+ const githubContributions = {
1311
+ scope: "github.contributions",
1312
+ pdppSource: "https://registry.pdpp.dev/connectors/github",
1313
+ pdppStreams: ["contributions"],
1314
+ legacySchemaPath: "github.contributions.json",
1315
+ fieldsRead: { contributions: ["date", "contribution_count"] },
1316
+ primaryKey: { contributions: ["id"] },
1317
+ lossy: [
1318
+ "daily records are projected to legacy days; level is absent from the source and omitted",
1319
+ "totalContributionsLastYear, yearTotals, monthlyTotals, and topDay are omitted because collected contribution records may cover only an incremental or caller-limited interval",
1320
+ "fetchedAt is set at projection time because contribution records have no collection timestamp",
1321
+ ],
1322
+ provenance: [
1323
+ {
1324
+ kind: "oci",
1325
+ ref: "ghcr.io/pdp-connect/connector/github:0.7.2",
1326
+ digest: "sha256:5501bb109cdca02d2acd08aa6891613c7af0bbe0869c30eba2a8070836b0d2f7",
1327
+ path: "declarations/github.collection-profile.json#streams[name=contributions]",
1328
+ },
1329
+ ],
1330
+ project(records, options) {
1331
+ const guard = requireStreams("github.contributions", options, [
1332
+ "contributions",
1333
+ ]);
1334
+ if (guard)
1335
+ return guard;
1336
+ const days = byStream(records, "contributions").flatMap((r) => {
1337
+ if (typeof r.date !== "string" ||
1338
+ typeof r.contribution_count !== "number")
1339
+ return [];
1340
+ return [{ date: r.date, count: r.contribution_count }];
1341
+ });
1342
+ return { ok: true, payload: { days, fetchedAt: projectionTime(options) } };
1343
+ },
1344
+ };
1345
+ // ---------------------------------------------------------------------------
1346
+ // openai (chatgpt)
1347
+ // declarations/chatgpt.collection-profile.json
1348
+ // connector_id: https://registry.pdpp.dev/connectors/chatgpt
1349
+ // ---------------------------------------------------------------------------
1350
+ const chatgptMemories = {
1351
+ scope: "chatgpt.memories",
1352
+ pdppSource: "https://registry.pdpp.dev/connectors/chatgpt",
1353
+ pdppStreams: ["memories"],
1354
+ legacySchemaPath: "chatgpt.memories.json",
1355
+ fieldsRead: { memories: ["id", "content", "created_at", "updated_at"] },
1356
+ primaryKey: { memories: ["id"] },
1357
+ lossy: [
1358
+ "type: legacy always emits it, defaulting to the literal string 'memory' when the upstream field is absent (chatgpt-playwright.js:645, `memory.type || 'memory'`); the memories stream has no `type` field at all, so this binding reproduces that same fallback rather than omitting the field the schema allows.",
1359
+ "created_at: a null value falls back to the projection time (options.now, else the wall clock), matching legacy's own `memory.created_at || new Date().toISOString()` fallback (N-M3).",
1360
+ ],
1361
+ provenance: [
1362
+ {
1363
+ kind: "oci",
1364
+ ref: "ghcr.io/pdp-connect/connector/chatgpt:0.1.0",
1365
+ digest: "sha256:811daaf8f2a9a346f1ff5109c118ea404212c8e00790cc5d448e89ad9fa9c4a4",
1366
+ path: "declarations/chatgpt.collection-profile.json#streams[name=memories]",
1367
+ },
1368
+ ],
1369
+ project(records, options) {
1370
+ const guard = requireStreams("chatgpt.memories", options, ["memories"]);
1371
+ if (guard) {
1372
+ return guard;
1373
+ }
1374
+ const items = byStream(records, "memories");
1375
+ // created_at is required by the legacy schema; the stream types it
1376
+ // nullable, so a null value falls back to now() the same way the
1377
+ // legacy connector does (chatgpt-playwright.js: `created_at:
1378
+ // memory.created_at || new Date().toISOString()`), rather than
1379
+ // passing through a schema-invalid null.
1380
+ return {
1381
+ ok: true,
1382
+ payload: {
1383
+ memories: items.map((m) => ({
1384
+ id: orEmptyString(m.id),
1385
+ content: orEmptyString(m.content),
1386
+ created_at: typeof m.created_at === "string"
1387
+ ? m.created_at
1388
+ : projectionTime(options),
1389
+ type: "memory",
1390
+ ...(typeof m.updated_at === "string"
1391
+ ? { updated_at: m.updated_at }
1392
+ : {}),
1393
+ })),
1394
+ total: items.length,
1395
+ },
1396
+ };
1397
+ },
1398
+ };
1399
+ // Signed ChatGPT 0.1.1 is the source; Desktop ingest remains disabled until
1400
+ // catalog admission and static_secret host setup land separately.
1401
+ const chatgptConversations = {
1402
+ scope: "chatgpt.conversations",
1403
+ pdppSource: "https://registry.pdpp.dev/connectors/chatgpt",
1404
+ pdppStreams: ["conversations", "messages"],
1405
+ legacySchemaPath: "chatgpt.conversations.json",
1406
+ fieldsRead: {
1407
+ conversations: [
1408
+ "id",
1409
+ "title",
1410
+ "create_time",
1411
+ "update_time",
1412
+ "current_node",
1413
+ "message_count_on_current_branch",
1414
+ ],
1415
+ messages: [
1416
+ "id",
1417
+ "conversation_id",
1418
+ "parent_id",
1419
+ "role",
1420
+ "content",
1421
+ "content_type",
1422
+ "model_slug",
1423
+ "create_time",
1424
+ "on_current_branch",
1425
+ ],
1426
+ },
1427
+ primaryKey: { conversations: ["id"], messages: ["id"] },
1428
+ lossy: [
1429
+ "Conversation create/update times pass through as null when the source lacks them, matching legacy's listed?.create_time ?? fetched.create_time ?? null fallback.",
1430
+ "fetched_at is the projection time (options.now, else the wall clock), matching legacy's per-run toConversationRecord timestamp.",
1431
+ "A conversation without complete current-branch message evidence, a valid id, or valid timestamps is dropped and counted in a records_dropped diagnostic; nested full threads are not invented. Messages whose conversation is absent are dropped and counted the same way.",
1432
+ "Only user/assistant text or multimodal_text messages with nonempty string content are retained, matching the legacy connector's walkMessages filter; message_count is the retained count.",
1433
+ "A missing title becomes 'Untitled', matching the legacy connector's toConversationRecord fallback; model_slug is renamed to model.",
1434
+ ],
1435
+ provenance: [
1436
+ {
1437
+ kind: "oci",
1438
+ ref: "ghcr.io/pdp-connect/connector/chatgpt:0.2.0",
1439
+ digest: "sha256:01777566b5163ca62bdb2bd21c316d09dd88f42c30a6d943ca101cfb635494ea",
1440
+ path: "declarations/chatgpt-0.2.0.collection-profile.json#streams[name=conversations,messages]",
1441
+ },
1442
+ ],
1443
+ project(records, options) {
1444
+ const scope = "chatgpt.conversations";
1445
+ const guard = requireStreams(scope, options, ["conversations"]);
1446
+ if (guard)
1447
+ return guard;
1448
+ // Without a messages stream at all, a read-time caller still serves the
1449
+ // conversation list (titles and times) with empty threads, and says so.
1450
+ const messagesMissing = !options.fetchedStreams.includes("messages");
1451
+ if (messagesMissing && !options.allowMissingJoinStreams)
1452
+ return missingStream(scope, "messages");
1453
+ const fetchedAt = projectionTime(options);
1454
+ // One malformed conversation must not make the whole scope unreadable:
1455
+ // it is left out and counted, by reason.
1456
+ const dropped = new Map();
1457
+ const drop = (reason) => dropped.set(reason, (dropped.get(reason) ?? 0) + 1);
1458
+ let malformedMessages = 0;
1459
+ const messagesByConversation = new Map();
1460
+ for (const message of byStream(records, "messages")) {
1461
+ if (!isObjectRow(message)) {
1462
+ malformedMessages += 1;
1463
+ continue;
1464
+ }
1465
+ if (typeof message.conversation_id !== "string")
1466
+ continue;
1467
+ const group = messagesByConversation.get(message.conversation_id) ?? [];
1468
+ group.push(message);
1469
+ messagesByConversation.set(message.conversation_id, group);
1470
+ }
1471
+ const sourceConversations = [];
1472
+ for (const row of byStream(records, "conversations")) {
1473
+ if (isObjectRow(row))
1474
+ sourceConversations.push(row);
1475
+ else
1476
+ drop("Conversation record is not an object");
1477
+ }
1478
+ const sourceIds = new Set(sourceConversations.map((c) => c.id));
1479
+ let orphanMessages = 0;
1480
+ for (const [id, group] of messagesByConversation) {
1481
+ if (!sourceIds.has(id))
1482
+ orphanMessages += group.length;
1483
+ }
1484
+ const conversations = [];
1485
+ for (const conversation of sourceConversations) {
1486
+ const { id, create_time, update_time, current_node, message_count_on_current_branch, } = conversation;
1487
+ if (!isOptionalString(create_time) || !isOptionalString(update_time)) {
1488
+ drop("Conversation timestamps must be strings, null, or absent");
1489
+ continue;
1490
+ }
1491
+ if (!isNonEmptyString(id)) {
1492
+ drop("Conversation lacks an id");
1493
+ continue;
1494
+ }
1495
+ const title = typeof conversation.title === "string" && conversation.title.length > 0
1496
+ ? conversation.title
1497
+ : "Untitled";
1498
+ if (messagesMissing) {
1499
+ conversations.push({
1500
+ id,
1501
+ title,
1502
+ create_time: create_time ?? null,
1503
+ update_time: update_time ?? null,
1504
+ message_count: 0,
1505
+ messages: [],
1506
+ fetched_at: fetchedAt,
1507
+ });
1508
+ continue;
1509
+ }
1510
+ if (typeof message_count_on_current_branch !== "number" ||
1511
+ !Number.isInteger(message_count_on_current_branch) ||
1512
+ message_count_on_current_branch < 0) {
1513
+ drop("Conversation lacks a current-branch message count");
1514
+ continue;
1515
+ }
1516
+ const branch = (messagesByConversation.get(id) ?? []).filter((m) => m.on_current_branch === true);
1517
+ if (branch.length !== message_count_on_current_branch) {
1518
+ drop("Current branch message count does not match conversation");
1519
+ continue;
1520
+ }
1521
+ const byId = new Map(branch.filter((m) => typeof m.id === "string").map((m) => [m.id, m]));
1522
+ const ordered = [];
1523
+ const seen = new Set();
1524
+ let node = typeof current_node === "string" ? current_node : null;
1525
+ while (node && byId.has(node) && !seen.has(node)) {
1526
+ seen.add(node);
1527
+ const message = byId.get(node);
1528
+ ordered.push(message);
1529
+ node = typeof message.parent_id === "string" ? message.parent_id : null;
1530
+ }
1531
+ if (ordered.length !== branch.length) {
1532
+ drop("Current branch message chain is incomplete");
1533
+ continue;
1534
+ }
1535
+ ordered.reverse();
1536
+ const messages = ordered
1537
+ .filter((m) => (m.role === "user" || m.role === "assistant") &&
1538
+ (m.content_type === "text" ||
1539
+ m.content_type === "multimodal_text") &&
1540
+ typeof m.content === "string" &&
1541
+ m.content.length > 0)
1542
+ .map((m) => ({
1543
+ id: m.id,
1544
+ role: m.role,
1545
+ content: m.content,
1546
+ content_type: m.content_type,
1547
+ create_time: typeof m.create_time === "string" ? m.create_time : null,
1548
+ model: typeof m.model_slug === "string" ? m.model_slug : null,
1549
+ }));
1550
+ conversations.push({
1551
+ id,
1552
+ title,
1553
+ create_time: create_time ?? null,
1554
+ update_time: update_time ?? null,
1555
+ message_count: messages.length,
1556
+ messages,
1557
+ fetched_at: fetchedAt,
1558
+ });
1559
+ }
1560
+ const diagnostics = [];
1561
+ if (dropped.size > 0) {
1562
+ diagnostics.push({
1563
+ kind: "records_dropped",
1564
+ scope,
1565
+ stream: "conversations",
1566
+ count: [...dropped.values()].reduce((sum, n) => sum + n, 0),
1567
+ reasons: [...dropped.keys()],
1568
+ });
1569
+ }
1570
+ if (orphanMessages + malformedMessages > 0) {
1571
+ diagnostics.push({
1572
+ kind: "records_dropped",
1573
+ scope,
1574
+ stream: "messages",
1575
+ count: orphanMessages + malformedMessages,
1576
+ reasons: [
1577
+ ...(orphanMessages > 0
1578
+ ? ["Messages have no matching conversation"]
1579
+ : []),
1580
+ ...(malformedMessages > 0 ? ["Message record is not an object"] : []),
1581
+ ],
1582
+ });
1583
+ }
1584
+ if (messagesMissing) {
1585
+ diagnostics.push({ kind: "stream_missing", scope, stream: "messages" });
1586
+ }
1587
+ // Legacy delivered the conversation list in ChatGPT's own order: most
1588
+ // recently updated first. A stable order keeps that order, not id order.
1589
+ if (options.orderByPrimaryKey)
1590
+ conversations.sort(newestUpdateFirst);
1591
+ return {
1592
+ ok: true,
1593
+ payload: { conversations, total: conversations.length },
1594
+ ...(diagnostics.length > 0 ? { diagnostics } : {}),
1595
+ };
1596
+ },
1597
+ };
1598
+ /**
1599
+ * Newest `update_time` first; a missing or unparseable time sorts last.
1600
+ * Ties fall back to the id, so the order never depends on input order.
1601
+ */
1602
+ function newestUpdateFirst(a, b) {
1603
+ const time = (row) => {
1604
+ const parsed = typeof row.update_time === "string" ? Date.parse(row.update_time) : NaN;
1605
+ return Number.isNaN(parsed) ? -Infinity : parsed;
1606
+ };
1607
+ const byTime = time(b) - time(a);
1608
+ if (byTime !== 0 && !Number.isNaN(byTime))
1609
+ return byTime;
1610
+ return String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0;
1611
+ }
1612
+ // This is the merged 0.1.3 source declaration. Admission remains a separate
1613
+ // decision; this local manifest hash is not the signed OCI artifact digest.
1614
+ const anthropicProjectionSource = "https://registry.pdpp.dev/connectors/anthropic";
1615
+ const anthropicProjectionProvenance = [
1616
+ {
1617
+ kind: "collection-profile",
1618
+ ref: "PDP-Connect/data-connectors ae44adf769 (#160)",
1619
+ digest: "sha256:1a156e586c81a95cb3bb1ad9c09c639990a22212074cba7da62268c326593372",
1620
+ path: "declarations/anthropic-0.1.3.collection-profile.json#streams[name=account_profile,conversations,messages,projects,project_documents]",
1621
+ },
1622
+ ];
1623
+ function claudeMetadata(scope, records) {
1624
+ const profiles = byStream(records, "account_profile");
1625
+ if (profiles.length !== 1) {
1626
+ return {
1627
+ ok: false,
1628
+ error: {
1629
+ kind: "incomplete_scope",
1630
+ scope,
1631
+ reason: "A single verified account_profile record is required",
1632
+ },
1633
+ };
1634
+ }
1635
+ const profile = profiles[0];
1636
+ if (typeof profile.id !== "string" ||
1637
+ !profile.id.trim() ||
1638
+ typeof profile.organization_id !== "string" ||
1639
+ !profile.organization_id.trim() ||
1640
+ profile.id !== profile.organization_id ||
1641
+ !(typeof profile.full_name === "string" || profile.full_name === null) ||
1642
+ !(typeof profile.plan === "string" || profile.plan === null) ||
1643
+ !["browser_menu", "users_json", "none"].includes(profile.name_source) ||
1644
+ !["valid", "absent", "malformed", "ambiguous", "mismatch"].includes(profile.metadata_status)) {
1645
+ return {
1646
+ ok: false,
1647
+ error: {
1648
+ kind: "invalid_value",
1649
+ scope,
1650
+ reason: "account_profile does not match its declared verified contract",
1651
+ },
1652
+ };
1653
+ }
1654
+ return {
1655
+ ok: true,
1656
+ profile: {
1657
+ name: profile.full_name,
1658
+ plan: profile.plan,
1659
+ },
1660
+ organizationId: profile.organization_id,
1661
+ };
1662
+ }
1663
+ function claudeConversationsBinding() {
1664
+ const scope = "claude.conversations";
1665
+ return {
1666
+ scope,
1667
+ pdppSource: anthropicProjectionSource,
1668
+ pdppStreams: ["account_profile", "conversations", "messages"],
1669
+ legacySchemaPath: "claude.conversations.json",
1670
+ fieldsRead: {
1671
+ account_profile: [
1672
+ "id",
1673
+ "organization_id",
1674
+ "full_name",
1675
+ "plan",
1676
+ "name_source",
1677
+ "metadata_status",
1678
+ ],
1679
+ conversations: [
1680
+ "id",
1681
+ "title",
1682
+ "create_time",
1683
+ "update_time",
1684
+ "project_id",
1685
+ "message_count",
1686
+ "is_starred",
1687
+ ],
1688
+ messages: [
1689
+ "id",
1690
+ "conversation_id",
1691
+ "role",
1692
+ "parent_id",
1693
+ "content",
1694
+ "create_time",
1695
+ "update_time",
1696
+ "attachments",
1697
+ ],
1698
+ },
1699
+ primaryKey: {
1700
+ account_profile: ["id"],
1701
+ conversations: ["id"],
1702
+ messages: ["id"],
1703
+ },
1704
+ lossy: [
1705
+ "href is reconstructed from the retained Claude route convention; rawContent and unavailable fetchError are omitted.",
1706
+ ],
1707
+ provenance: anthropicProjectionProvenance,
1708
+ project(records, options) {
1709
+ const guard = requireStreams(scope, options, [
1710
+ "account_profile",
1711
+ "conversations",
1712
+ "messages",
1713
+ ]);
1714
+ if (guard)
1715
+ return guard;
1716
+ const metadata = claudeMetadata(scope, records);
1717
+ if (!metadata.ok)
1718
+ return metadata;
1719
+ const conversations = byStream(records, "conversations");
1720
+ const messagesByConversation = new Map();
1721
+ for (const message of byStream(records, "messages")) {
1722
+ if (typeof message.conversation_id !== "string")
1723
+ return {
1724
+ ok: false,
1725
+ error: {
1726
+ kind: "invalid_value",
1727
+ scope,
1728
+ reason: "Message has no conversation id",
1729
+ },
1730
+ };
1731
+ const rows = messagesByConversation.get(message.conversation_id) ?? [];
1732
+ rows.push(message);
1733
+ messagesByConversation.set(message.conversation_id, rows);
1734
+ }
1735
+ const ids = new Set(conversations.map((conversation) => conversation.id));
1736
+ if ([...messagesByConversation.keys()].some((id) => !ids.has(id)))
1737
+ return {
1738
+ ok: false,
1739
+ error: {
1740
+ kind: "invalid_value",
1741
+ scope,
1742
+ reason: "Messages have no matching conversation",
1743
+ },
1744
+ };
1745
+ const projected = [];
1746
+ for (const conversation of conversations) {
1747
+ if (typeof conversation.id !== "string" ||
1748
+ !conversation.id.trim() ||
1749
+ !(typeof conversation.title === "string" ||
1750
+ conversation.title === null))
1751
+ return {
1752
+ ok: false,
1753
+ error: {
1754
+ kind: "invalid_value",
1755
+ scope,
1756
+ reason: "Conversation id or title has invalid source shape",
1757
+ },
1758
+ };
1759
+ const sourceMessages = messagesByConversation.get(conversation.id) ?? [];
1760
+ if (sourceMessages.some((message) => typeof message.id !== "string" ||
1761
+ !(typeof message.content === "string" || message.content === null) ||
1762
+ !(Array.isArray(message.attachments) ||
1763
+ message.attachments === null)))
1764
+ return {
1765
+ ok: false,
1766
+ error: {
1767
+ kind: "invalid_value",
1768
+ scope,
1769
+ reason: "Message does not match declared source shape",
1770
+ },
1771
+ };
1772
+ const messages = [...sourceMessages]
1773
+ .sort((a, b) => (Date.parse(a.create_time || "") || 0) -
1774
+ (Date.parse(b.create_time || "") || 0))
1775
+ .map((message) => {
1776
+ return {
1777
+ id: message.id,
1778
+ sender: message.role ?? null,
1779
+ parentId: message.parent_id ?? null,
1780
+ createdAt: message.create_time ?? null,
1781
+ updatedAt: message.update_time ?? null,
1782
+ content: message.content ?? "",
1783
+ attachments: message.attachments ?? [],
1784
+ };
1785
+ });
1786
+ const messageCount = typeof conversation.message_count === "number"
1787
+ ? conversation.message_count
1788
+ : messages.length;
1789
+ if (messageCount !== messages.length)
1790
+ return {
1791
+ ok: false,
1792
+ error: {
1793
+ kind: "incomplete_scope",
1794
+ scope,
1795
+ reason: "Fetched messages do not match conversation message_count",
1796
+ },
1797
+ };
1798
+ projected.push({
1799
+ id: conversation.id,
1800
+ title: conversation.title || "Untitled",
1801
+ href: `/chat/${conversation.id}`,
1802
+ createdAt: conversation.create_time ?? null,
1803
+ updatedAt: conversation.update_time ?? null,
1804
+ starred: conversation.is_starred ?? null,
1805
+ projectId: conversation.project_id ?? null,
1806
+ messageCount,
1807
+ messages,
1808
+ });
1809
+ }
1810
+ return {
1811
+ ok: true,
1812
+ payload: {
1813
+ profile: metadata.profile,
1814
+ organizationId: metadata.organizationId,
1815
+ conversations: projected,
1816
+ total: projected.length,
1817
+ messageTotal: projected.reduce((total, conversation) => total + conversation.messageCount, 0),
1818
+ source: "official-export",
1819
+ },
1820
+ };
1821
+ },
1822
+ };
1823
+ }
1824
+ const claudeConversations = claudeConversationsBinding();
1825
+ const claudeProjects = {
1826
+ scope: "claude.projects",
1827
+ pdppSource: anthropicProjectionSource,
1828
+ pdppStreams: ["account_profile", "projects", "project_documents"],
1829
+ legacySchemaPath: "claude.projects.json",
1830
+ fieldsRead: {
1831
+ account_profile: [
1832
+ "id",
1833
+ "organization_id",
1834
+ "full_name",
1835
+ "plan",
1836
+ "name_source",
1837
+ "metadata_status",
1838
+ ],
1839
+ projects: [
1840
+ "id",
1841
+ "name",
1842
+ "description",
1843
+ "create_time",
1844
+ "update_time",
1845
+ "is_archived",
1846
+ "prompt_template",
1847
+ "creator",
1848
+ "is_private",
1849
+ "is_starter_project",
1850
+ "archived_at",
1851
+ "raw_docs",
1852
+ ],
1853
+ project_documents: [
1854
+ "id",
1855
+ "project_id",
1856
+ "filename",
1857
+ "content",
1858
+ "create_time",
1859
+ "update_time",
1860
+ ],
1861
+ },
1862
+ primaryKey: {
1863
+ account_profile: ["id"],
1864
+ projects: ["id"],
1865
+ project_documents: ["id"],
1866
+ },
1867
+ lossy: [
1868
+ "detail carries only declared project and raw document fields. Other raw-only keys and documents dropped by source parsing cannot be recovered; href and label follow the legacy connector's route and label rules.",
1869
+ ],
1870
+ provenance: anthropicProjectionProvenance,
1871
+ project(records, options) {
1872
+ const scope = "claude.projects";
1873
+ const guard = requireStreams(scope, options, [
1874
+ "account_profile",
1875
+ "projects",
1876
+ "project_documents",
1877
+ ]);
1878
+ if (guard)
1879
+ return guard;
1880
+ const metadata = claudeMetadata(scope, records);
1881
+ if (!metadata.ok)
1882
+ return metadata;
1883
+ const documents = byStream(records, "project_documents");
1884
+ const projects = byStream(records, "projects");
1885
+ const projectIds = new Set(projects.map((project) => project.id));
1886
+ if (documents.some((document) => typeof document.project_id !== "string" ||
1887
+ !projectIds.has(document.project_id)))
1888
+ return {
1889
+ ok: false,
1890
+ error: {
1891
+ kind: "invalid_value",
1892
+ scope,
1893
+ reason: "Project document has no matching project",
1894
+ },
1895
+ };
1896
+ const projected = [];
1897
+ for (const project of projects) {
1898
+ if (typeof project.id !== "string" ||
1899
+ !project.id.trim() ||
1900
+ typeof project.name !== "string")
1901
+ return {
1902
+ ok: false,
1903
+ error: {
1904
+ kind: "invalid_value",
1905
+ scope,
1906
+ reason: "Project id and name are required by the legacy payload",
1907
+ },
1908
+ };
1909
+ const docs = documents
1910
+ .filter((document) => document.project_id === project.id)
1911
+ .map((document) => ({
1912
+ uuid: document.id,
1913
+ filename: document.filename ?? null,
1914
+ content: document.content ?? null,
1915
+ created_at: document.create_time ?? null,
1916
+ updated_at: document.update_time ?? null,
1917
+ }));
1918
+ const detail = {
1919
+ uuid: project.id,
1920
+ name: project.name,
1921
+ docs,
1922
+ };
1923
+ for (const [sourceKey, legacyKey] of [
1924
+ ["description", "description"],
1925
+ ["create_time", "created_at"],
1926
+ ["update_time", "updated_at"],
1927
+ ["prompt_template", "prompt_template"],
1928
+ ]) {
1929
+ if (project[sourceKey] !== undefined)
1930
+ detail[legacyKey] = project[sourceKey];
1931
+ }
1932
+ for (const key of ["is_private", "is_starter_project"]) {
1933
+ const value = project[key];
1934
+ if (value !== undefined) {
1935
+ if (value !== null && typeof value !== "boolean")
1936
+ return {
1937
+ ok: false,
1938
+ error: {
1939
+ kind: "invalid_value",
1940
+ scope,
1941
+ reason: `${key} has invalid source shape`,
1942
+ },
1943
+ };
1944
+ detail[key] = value;
1945
+ }
1946
+ }
1947
+ if (project.archived_at !== undefined) {
1948
+ if (project.archived_at !== null &&
1949
+ typeof project.archived_at !== "string")
1950
+ return {
1951
+ ok: false,
1952
+ error: {
1953
+ kind: "invalid_value",
1954
+ scope,
1955
+ reason: "archived_at has invalid source shape",
1956
+ },
1957
+ };
1958
+ detail.archived_at = project.archived_at;
1959
+ }
1960
+ if (project.creator !== undefined) {
1961
+ const creator = project.creator;
1962
+ if (creator !== null &&
1963
+ (typeof creator !== "object" || Array.isArray(creator)))
1964
+ return {
1965
+ ok: false,
1966
+ error: {
1967
+ kind: "invalid_value",
1968
+ scope,
1969
+ reason: "creator has invalid source shape",
1970
+ },
1971
+ };
1972
+ if (creator === null)
1973
+ detail.creator = null;
1974
+ else {
1975
+ const value = creator;
1976
+ if (["uuid", "full_name"].some((key) => value[key] !== undefined && typeof value[key] !== "string"))
1977
+ return {
1978
+ ok: false,
1979
+ error: {
1980
+ kind: "invalid_value",
1981
+ scope,
1982
+ reason: "creator fields have invalid source shape",
1983
+ },
1984
+ };
1985
+ detail.creator = Object.fromEntries(["uuid", "full_name"]
1986
+ .filter((key) => value[key] !== undefined)
1987
+ .map((key) => [key, value[key]]));
1988
+ }
1989
+ }
1990
+ if (project.raw_docs !== undefined) {
1991
+ if (!Array.isArray(project.raw_docs))
1992
+ return {
1993
+ ok: false,
1994
+ error: {
1995
+ kind: "invalid_value",
1996
+ scope,
1997
+ reason: "raw_docs has invalid source shape",
1998
+ },
1999
+ };
2000
+ const rawKeys = [
2001
+ "uuid",
2002
+ "filename",
2003
+ "content",
2004
+ "created_at",
2005
+ "updated_at",
2006
+ ];
2007
+ const rawDocs = [];
2008
+ for (const raw of project.raw_docs) {
2009
+ if (!raw ||
2010
+ typeof raw !== "object" ||
2011
+ Array.isArray(raw) ||
2012
+ rawKeys.some((key) => raw[key] !== undefined &&
2013
+ typeof raw[key] !== "string"))
2014
+ return {
2015
+ ok: false,
2016
+ error: {
2017
+ kind: "invalid_value",
2018
+ scope,
2019
+ reason: "raw document has invalid source shape",
2020
+ },
2021
+ };
2022
+ const value = raw;
2023
+ rawDocs.push(Object.fromEntries(rawKeys
2024
+ .filter((key) => value[key] !== undefined)
2025
+ .map((key) => [key, value[key]])));
2026
+ }
2027
+ detail.raw_docs = rawDocs;
2028
+ }
2029
+ projected.push({
2030
+ id: project.id,
2031
+ title: project.name,
2032
+ href: `/project/${project.id}`,
2033
+ label: `Project, ${project.name}`,
2034
+ createdAt: project.create_time ?? null,
2035
+ updatedAt: project.update_time ?? null,
2036
+ // Legacy emits `Boolean(p?.archived_at)` (claude-export-playwright.js
2037
+ // normalizeProject), so the value is always a boolean, never null.
2038
+ archived: project.is_archived === true || Boolean(project.archived_at),
2039
+ detail,
2040
+ });
2041
+ }
2042
+ return {
2043
+ ok: true,
2044
+ payload: {
2045
+ profile: metadata.profile,
2046
+ organizationId: metadata.organizationId,
2047
+ projects: projected,
2048
+ total: projected.length,
2049
+ source: "official-export",
2050
+ },
2051
+ };
2052
+ },
2053
+ };
2054
+ // ---------------------------------------------------------------------------
2055
+ // linkedin
2056
+ // declarations/linkedin.collection-profile.json
2057
+ // connector_id: https://registry.pdpp.dev/connectors/linkedin
2058
+ // ---------------------------------------------------------------------------
2059
+ const linkedinProfile = {
2060
+ scope: "linkedin.profile",
2061
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2062
+ pdppStreams: ["profile"],
2063
+ legacySchemaPath: "linkedin.profile.json",
2064
+ fieldsRead: {
2065
+ profile: [
2066
+ "public_url",
2067
+ "full_name",
2068
+ "headline",
2069
+ "location",
2070
+ "connection_count",
2071
+ "profile_picture_url",
2072
+ "summary",
2073
+ ],
2074
+ },
2075
+ primaryKey: { profile: ["id"] },
2076
+ lossy: [
2077
+ "public_url, full_name, headline, location, profile_picture_url, summary: null/missing values become '' to satisfy the required legacy string fields",
2078
+ "connection_count: absent or non-finite/non-number values become '0'; the legacy connector could replace its initial '0' with a fetched connection count, which this projection does not derive",
2079
+ ],
2080
+ provenance: [
2081
+ {
2082
+ kind: "oci",
2083
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2084
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2085
+ path: "declarations/linkedin.collection-profile.json#streams[name=profile]",
2086
+ },
2087
+ ],
2088
+ project(records, options) {
2089
+ const guard = requireStreams("linkedin.profile", options, ["profile"]);
2090
+ if (guard) {
2091
+ return guard;
2092
+ }
2093
+ const [profile] = byStream(records, "profile");
2094
+ if (!profile) {
2095
+ return {
2096
+ ok: false,
2097
+ error: { kind: "empty_singleton", scope: "linkedin.profile" },
2098
+ };
2099
+ }
2100
+ return {
2101
+ ok: true,
2102
+ payload: {
2103
+ profileUrl: orEmptyString(profile.public_url),
2104
+ fullName: orEmptyString(profile.full_name),
2105
+ headline: orEmptyString(profile.headline),
2106
+ location: orEmptyString(profile.location),
2107
+ connections: typeof profile.connection_count === "number" &&
2108
+ Number.isFinite(profile.connection_count)
2109
+ ? String(profile.connection_count)
2110
+ : "0",
2111
+ profilePictureUrl: orEmptyString(profile.profile_picture_url),
2112
+ // The legacy connector reads this same Voyager summary as `about`.
2113
+ about: orEmptyString(profile.summary),
2114
+ },
2115
+ };
2116
+ },
2117
+ };
2118
+ const linkedinConnections = {
2119
+ scope: "linkedin.connections",
2120
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2121
+ pdppStreams: ["connections"],
2122
+ legacySchemaPath: "linkedin.connections.json",
2123
+ fieldsRead: {
2124
+ connections: ["id", "full_name", "headline", "profile_url", "connected_at"],
2125
+ },
2126
+ primaryKey: { connections: ["id"] },
2127
+ lossy: [
2128
+ "Null full_name, headline, and profile_url become empty strings, matching the legacy connector's own fallback; connected_at is reduced to the legacy YYYY-MM-DD date text, with missing/invalid values becoming the legacy empty-string fallback.",
2129
+ ],
2130
+ provenance: [
2131
+ {
2132
+ kind: "oci",
2133
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2134
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2135
+ path: "declarations/linkedin.collection-profile.json#streams[name=connections]",
2136
+ },
2137
+ ],
2138
+ project(records, options) {
2139
+ const guard = requireStreams("linkedin.connections", options, [
2140
+ "connections",
2141
+ ]);
2142
+ if (guard)
2143
+ return guard;
2144
+ return {
2145
+ ok: true,
2146
+ payload: {
2147
+ connections: byStream(records, "connections").map((r) => ({
2148
+ fullName: orEmptyString(r.full_name),
2149
+ headline: orEmptyString(r.headline),
2150
+ profileUrl: orEmptyString(r.profile_url),
2151
+ dateConnected: typeof r.connected_at === "string" &&
2152
+ !Number.isNaN(new Date(r.connected_at).getTime())
2153
+ ? new Date(r.connected_at).toISOString().slice(0, 10)
2154
+ : "",
2155
+ })),
2156
+ },
2157
+ };
2158
+ },
2159
+ };
2160
+ const linkedinExperience = {
2161
+ scope: "linkedin.experience",
2162
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2163
+ pdppStreams: ["experience"],
2164
+ legacySchemaPath: "linkedin.experience.json",
2165
+ fieldsRead: {
2166
+ experience: [
2167
+ "title",
2168
+ "company",
2169
+ "start_date",
2170
+ "end_date",
2171
+ "location",
2172
+ "description",
2173
+ ],
2174
+ },
2175
+ primaryKey: { experience: ["id"] },
2176
+ lossy: [
2177
+ "location, description: PDPP-side null projects as '' (matches legacy's own `pos.locationName || ''` / `pos.description || ''`)",
2178
+ ],
2179
+ provenance: [
2180
+ {
2181
+ kind: "oci",
2182
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2183
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2184
+ path: "declarations/linkedin.collection-profile.json#streams[name=experience]",
2185
+ },
2186
+ ],
2187
+ project(records, options) {
2188
+ const guard = requireStreams("linkedin.experience", options, [
2189
+ "experience",
2190
+ ]);
2191
+ if (guard) {
2192
+ return guard;
2193
+ }
2194
+ const items = byStream(records, "experience");
2195
+ // Legacy `dates` is one free-text field, "M/YYYY - M/YYYY" or
2196
+ // "M/YYYY - Present" (linkedin-playwright.js:129-145,
2197
+ // extractTimePeriod). PDPP declares separate start_date/end_date as
2198
+ // date-time strings; toLegacyTimePeriod reproduces the exact legacy
2199
+ // format from those two real fields, not a fabricated one.
2200
+ return {
2201
+ ok: true,
2202
+ payload: {
2203
+ experiences: items.map((e) => ({
2204
+ jobTitle: orEmptyString(e.title),
2205
+ companyName: orEmptyString(e.company),
2206
+ dates: toLegacyTimePeriod(e.start_date, e.end_date),
2207
+ location: orEmptyString(e.location),
2208
+ description: orEmptyString(e.description),
2209
+ })),
2210
+ },
2211
+ };
2212
+ },
2213
+ };
2214
+ const linkedinEducation = {
2215
+ scope: "linkedin.education",
2216
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2217
+ pdppStreams: ["education"],
2218
+ legacySchemaPath: "linkedin.education.json",
2219
+ fieldsRead: {
2220
+ education: [
2221
+ "school",
2222
+ "degree",
2223
+ "field_of_study",
2224
+ "start_date",
2225
+ "end_date",
2226
+ "grade",
2227
+ "logo_url",
2228
+ ],
2229
+ },
2230
+ primaryKey: { education: ["id"] },
2231
+ lossy: [
2232
+ "school, degree, grade, logo_url: null/missing values become '' to satisfy the required legacy string fields",
2233
+ ],
2234
+ provenance: [
2235
+ {
2236
+ kind: "oci",
2237
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2238
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2239
+ path: "declarations/linkedin.collection-profile.json#streams[name=education]",
2240
+ },
2241
+ ],
2242
+ project(records, options) {
2243
+ const guard = requireStreams("linkedin.education", options, ["education"]);
2244
+ if (guard) {
2245
+ return guard;
2246
+ }
2247
+ const items = byStream(records, "education");
2248
+ return {
2249
+ ok: true,
2250
+ payload: {
2251
+ education: items.map((e) => ({
2252
+ schoolName: orEmptyString(e.school),
2253
+ degree: [orEmptyString(e.degree), orEmptyString(e.field_of_study)]
2254
+ .filter(Boolean)
2255
+ .join(", "),
2256
+ years: toLegacyTimePeriod(e.start_date, e.end_date),
2257
+ grade: orEmptyString(e.grade),
2258
+ logoUrl: orEmptyString(e.logo_url),
2259
+ })),
2260
+ },
2261
+ };
2262
+ },
2263
+ };
2264
+ const linkedinLanguages = {
2265
+ scope: "linkedin.languages",
2266
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2267
+ pdppStreams: ["languages"],
2268
+ legacySchemaPath: "linkedin.languages.json",
2269
+ fieldsRead: { languages: ["name", "proficiency"] },
2270
+ primaryKey: { languages: ["id"] },
2271
+ lossy: [
2272
+ "proficiency: null/missing values become '' to satisfy the required legacy string field",
2273
+ ],
2274
+ provenance: [
2275
+ {
2276
+ kind: "oci",
2277
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2278
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2279
+ path: "declarations/linkedin.collection-profile.json#streams[name=languages]",
2280
+ },
2281
+ ],
2282
+ project(records, options) {
2283
+ const guard = requireStreams("linkedin.languages", options, ["languages"]);
2284
+ if (guard) {
2285
+ return guard;
2286
+ }
2287
+ const items = byStream(records, "languages");
2288
+ return {
2289
+ ok: true,
2290
+ payload: {
2291
+ languages: items.map((l) => ({
2292
+ name: orEmptyString(l.name),
2293
+ proficiency: orEmptyString(l.proficiency),
2294
+ })),
2295
+ },
2296
+ };
2297
+ },
2298
+ };
2299
+ const linkedinSkills = {
2300
+ scope: "linkedin.skills",
2301
+ pdppSource: "https://registry.pdpp.dev/connectors/linkedin",
2302
+ pdppStreams: ["skills"],
2303
+ legacySchemaPath: "linkedin.skills.json",
2304
+ fieldsRead: { skills: ["name", "endorsement_count"] },
2305
+ primaryKey: { skills: ["id"] },
2306
+ lossy: [],
2307
+ provenance: [
2308
+ {
2309
+ kind: "oci",
2310
+ ref: "ghcr.io/pdp-connect/connector/linkedin:0.3.1",
2311
+ digest: "sha256:7544a6c015bf4b730afdd97a7521608d10d1c077950af5e9f3adc9405609fd68",
2312
+ path: "declarations/linkedin.collection-profile.json#streams[name=skills]",
2313
+ },
2314
+ ],
2315
+ project(records, options) {
2316
+ const guard = requireStreams("linkedin.skills", options, ["skills"]);
2317
+ if (guard) {
2318
+ return guard;
2319
+ }
2320
+ const items = byStream(records, "skills");
2321
+ // Legacy `endorsements` is a string; PDPP `endorsement_count` is a
2322
+ // number. String-converted with legacy's own 0 fallback
2323
+ // (linkedin-playwright.js:551, `String(sk.endorsementCount || ... || 0)`).
2324
+ return {
2325
+ ok: true,
2326
+ payload: {
2327
+ skills: items.map((s) => ({
2328
+ name: orEmptyString(s.name),
2329
+ endorsements: String(orZero(s.endorsement_count)),
2330
+ })),
2331
+ },
2332
+ };
2333
+ },
2334
+ };
2335
+ // ---------------------------------------------------------------------------
2336
+ // spotify
2337
+ // declarations/spotify.collection-profile.json (0.1.4)
2338
+ // connector_id: https://registry.pdpp.dev/connectors/spotify
2339
+ // ---------------------------------------------------------------------------
2340
+ const spotifyProfile = {
2341
+ scope: "spotify.profile",
2342
+ pdppSource: "https://registry.pdpp.dev/connectors/spotify",
2343
+ pdppStreams: ["profile"],
2344
+ legacySchemaPath: "spotify.profile.json",
2345
+ fieldsRead: {
2346
+ profile: ["id", "display_name", "uri", "followers", "following", "images"],
2347
+ },
2348
+ primaryKey: { profile: ["id"] },
2349
+ lossy: [
2350
+ "display_name: PDPP permits null, but the legacy schema requires a string; null projects as ''.",
2351
+ "images: legacy stores string URLs. String entries are retained, and structured PDPP image entries project their string url field.",
2352
+ ],
2353
+ provenance: [
2354
+ {
2355
+ kind: "oci",
2356
+ ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
2357
+ digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
2358
+ path: "declarations/spotify.collection-profile.json#streams[name=profile]",
2359
+ },
2360
+ ],
2361
+ project(records, options) {
2362
+ const guard = requireStreams("spotify.profile", options, ["profile"]);
2363
+ if (guard) {
2364
+ return guard;
2365
+ }
2366
+ const [profile] = byStream(records, "profile");
2367
+ if (!profile) {
2368
+ return {
2369
+ ok: false,
2370
+ error: { kind: "empty_singleton", scope: "spotify.profile" },
2371
+ };
2372
+ }
2373
+ return {
2374
+ ok: true,
2375
+ payload: {
2376
+ id: orEmptyString(profile.id),
2377
+ display_name: orEmptyString(profile.display_name),
2378
+ uri: orEmptyString(profile.uri),
2379
+ followers: orZero(profile.followers),
2380
+ following: orZero(profile.following),
2381
+ images: spotifyImageUrls(profile.images),
2382
+ },
2383
+ };
2384
+ },
2385
+ };
2386
+ const spotifySavedTracks = {
2387
+ scope: "spotify.savedTracks",
2388
+ pdppSource: "https://registry.pdpp.dev/connectors/spotify",
2389
+ pdppStreams: ["saved_tracks"],
2390
+ legacySchemaPath: "spotify.savedTracks.json",
2391
+ fieldsRead: {
2392
+ saved_tracks: [
2393
+ "id",
2394
+ "name",
2395
+ "artist_names",
2396
+ "album_name",
2397
+ "duration_ms",
2398
+ "added_at",
2399
+ "uri",
2400
+ "explicit",
2401
+ "album_artist_names",
2402
+ ],
2403
+ },
2404
+ primaryKey: { saved_tracks: ["id"] },
2405
+ lossy: [
2406
+ "Rows without the required legacy name and artist list are skipped; nullable album and optional fields retain the connector's empty-string, zero, and false sentinels.",
2407
+ "isrc, popularity, and id have no corresponding legacy fields and are omitted.",
2408
+ ],
2409
+ provenance: [
2410
+ {
2411
+ kind: "oci",
2412
+ ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
2413
+ digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
2414
+ path: "declarations/spotify.collection-profile.json#streams[name=saved_tracks]",
2415
+ },
2416
+ ],
2417
+ project(records, options) {
2418
+ const guard = requireStreams("spotify.savedTracks", options, [
2419
+ "saved_tracks",
2420
+ ]);
2421
+ if (guard)
2422
+ return guard;
2423
+ const tracks = byStream(records, "saved_tracks").flatMap((r) => {
2424
+ if (typeof r.name !== "string" ||
2425
+ !Array.isArray(r.artist_names) ||
2426
+ !r.artist_names.every((name) => typeof name === "string"))
2427
+ return [];
2428
+ return [
2429
+ {
2430
+ added_at: orEmptyString(r.added_at),
2431
+ name: r.name,
2432
+ artists: r.artist_names.map((name) => ({ name })),
2433
+ album: {
2434
+ name: orEmptyString(r.album_name),
2435
+ artists: Array.isArray(r.album_artist_names)
2436
+ ? r.album_artist_names
2437
+ .filter((name) => typeof name === "string")
2438
+ .map((name) => ({ name }))
2439
+ : [],
2440
+ },
2441
+ duration_ms: orZero(r.duration_ms),
2442
+ uri: orEmptyString(r.uri),
2443
+ explicit: typeof r.explicit === "boolean" ? r.explicit : false,
2444
+ },
2445
+ ];
2446
+ });
2447
+ return { ok: true, payload: { savedTracks: tracks, total: tracks.length } };
2448
+ },
2449
+ };
2450
+ const spotifyPlaylists = {
2451
+ scope: "spotify.playlists",
2452
+ pdppSource: "https://registry.pdpp.dev/connectors/spotify",
2453
+ pdppStreams: ["playlists", "playlist_items"],
2454
+ legacySchemaPath: "spotify.playlists.json",
2455
+ fieldsRead: {
2456
+ playlists: [
2457
+ "id",
2458
+ "name",
2459
+ "description",
2460
+ "owner_name",
2461
+ "uri",
2462
+ "followers",
2463
+ "images",
2464
+ "track_count",
2465
+ ],
2466
+ playlist_items: [
2467
+ "playlist_id",
2468
+ "position",
2469
+ "added_at",
2470
+ "added_by",
2471
+ "name",
2472
+ "artist_names",
2473
+ "album_name",
2474
+ "duration_ms",
2475
+ "track_id",
2476
+ ],
2477
+ },
2478
+ primaryKey: {
2479
+ playlists: ["id"],
2480
+ playlist_items: ["id"],
2481
+ },
2482
+ lossy: [
2483
+ "tracks[].uri: raw playlist item URIs are retained when available; older pinned records fall back to a canonical Spotify track ID, and a null ID uses the retained collector's empty-string sentinel.",
2484
+ "tracks_total counts joined fetched tracks, as in the retained collector; catalog track_count is not a fetched-row count.",
2485
+ "images: legacy stores string URLs. String entries are retained, and structured PDPP image entries project their string url field.",
2486
+ ],
2487
+ provenance: [
2488
+ {
2489
+ kind: "oci",
2490
+ ref: "ghcr.io/pdp-connect/connector/spotify:0.1.4",
2491
+ digest: "sha256:6989f4eceb2142456bebb7b3d442e0a81ce571ef762824aa6dca882afabcba08",
2492
+ path: "declarations/spotify.collection-profile.json#streams[name=playlists,playlist_items]",
2493
+ },
2494
+ ],
2495
+ project(records, options) {
2496
+ const guard = requireStreams("spotify.playlists", options, [
2497
+ "playlists",
2498
+ "playlist_items",
2499
+ ]);
2500
+ if (guard) {
2501
+ return guard;
2502
+ }
2503
+ const itemsByPlaylist = new Map();
2504
+ for (const item of byStream(records, "playlist_items")) {
2505
+ const playlistId = item.playlist_id;
2506
+ if (typeof playlistId !== "string")
2507
+ continue;
2508
+ const group = itemsByPlaylist.get(playlistId);
2509
+ if (group) {
2510
+ group.push(item);
2511
+ }
2512
+ else {
2513
+ itemsByPlaylist.set(playlistId, [item]);
2514
+ }
2515
+ }
2516
+ const sourcePlaylists = byStream(records, "playlists");
2517
+ const playlistIds = new Set(sourcePlaylists.map((playlist) => playlist.id));
2518
+ const unrepresentablePlaylist = [...itemsByPlaylist].some(([id, tracks]) => !playlistIds.has(id) ||
2519
+ tracks.some((track) => typeof track.uri !== "string" &&
2520
+ track.track_id != null &&
2521
+ (typeof track.track_id !== "string" ||
2522
+ !/^(?:spotify:track:)?[A-Za-z0-9]{22}$/.test(track.track_id))));
2523
+ if (unrepresentablePlaylist) {
2524
+ return {
2525
+ ok: false,
2526
+ error: {
2527
+ kind: "incomplete_scope",
2528
+ scope: "spotify.playlists",
2529
+ reason: "Cannot join every playlist item or derive its Spotify track URI",
2530
+ },
2531
+ };
2532
+ }
2533
+ const playlists = sourcePlaylists.map((playlist) => {
2534
+ const id = typeof playlist.id === "string" ? playlist.id : "";
2535
+ const trackItems = [...(itemsByPlaylist.get(id) ?? [])].sort((a, b) => orZero(a.position) - orZero(b.position));
2536
+ return {
2537
+ name: orEmptyString(playlist.name),
2538
+ description: orEmptyString(playlist.description),
2539
+ owner: orEmptyString(playlist.owner_name),
2540
+ uri: orEmptyString(playlist.uri),
2541
+ followers: orZero(playlist.followers),
2542
+ images: spotifyImageUrls(playlist.images),
2543
+ tracks_total: trackItems.length,
2544
+ tracks: trackItems.map((track) => ({
2545
+ added_at: orEmptyString(track.added_at),
2546
+ added_by: orEmptyString(track.added_by),
2547
+ name: orEmptyString(track.name),
2548
+ artists: Array.isArray(track.artist_names)
2549
+ ? track.artist_names
2550
+ .filter((artist) => typeof artist === "string")
2551
+ .map((name) => ({ name }))
2552
+ : [],
2553
+ album: orEmptyString(track.album_name),
2554
+ duration_ms: orZero(track.duration_ms),
2555
+ uri: typeof track.uri === "string"
2556
+ ? track.uri
2557
+ : typeof track.track_id === "string"
2558
+ ? track.track_id.startsWith("spotify:track:")
2559
+ ? track.track_id
2560
+ : `spotify:track:${track.track_id}`
2561
+ : "",
2562
+ })),
2563
+ };
2564
+ });
2565
+ return {
2566
+ ok: true,
2567
+ payload: { playlists, total: playlists.length },
2568
+ };
2569
+ },
2570
+ };
2571
+ function spotifyImageUrls(value) {
2572
+ if (!Array.isArray(value))
2573
+ return [];
2574
+ return value.flatMap((image) => {
2575
+ if (typeof image === "string")
2576
+ return [image];
2577
+ if (typeof image !== "object" || image === null)
2578
+ return [];
2579
+ const url = image.url;
2580
+ return typeof url === "string" ? [url] : [];
2581
+ });
2582
+ }
2583
+ // ---------------------------------------------------------------------------
2584
+ // shopify (shop.orders)
2585
+ // declarations/shopify.collection-profile.json (0.2.0)
2586
+ // connector_id: https://registry.pdpp.dev/connectors/shopify
2587
+ // ---------------------------------------------------------------------------
2588
+ const shopOrders = {
2589
+ scope: "shop.orders",
2590
+ pdppSource: "https://registry.pdpp.dev/connectors/shopify",
2591
+ pdppStreams: ["orders"],
2592
+ legacySchemaPath: "shop.orders.json",
2593
+ fieldsRead: {
2594
+ orders: [
2595
+ "id",
2596
+ "order_date",
2597
+ "merchant_name",
2598
+ "total_cents",
2599
+ "currency",
2600
+ "status",
2601
+ "item_count",
2602
+ "line_item_titles",
2603
+ "detail_url",
2604
+ ],
2605
+ },
2606
+ primaryKey: { orders: ["id"] },
2607
+ lossy: [
2608
+ "orderNumber: PDPP provides order_number, but the legacy connector derives orderNumber from id (shop-playwright.js:174); keep the existing value to preserve the scope contract",
2609
+ "status: legacy uses displayStatus free text scraped from the storefront; PDPP's normalized `status` value is not verified to match that text 1:1",
2610
+ "placedAt (F5): passed through only when the PDPP source value parses as a valid ISO 8601 date-time; otherwise omitted. legacy's own no-match fallback is '' (shop-playwright.js:175, `orderObj.createdAt || ''`), which the pinned schema does not accept (placedAt is `format: date-time`). A null-shaped legacy fallback would itself fail the pinned schema.",
2611
+ "total, itemCount (N-S1): legacy's own no-match fallbacks are null (:154, top-level total) and `totalItemCount ?? (itemTitles.length || null)` (:180) — none of which the pinned per-item schema accepts (both are `type: number`). A null-shaped legacy fallback would itself fail the pinned schema, and only `id` is required per item, so a missing PDPP value for these two fields is OMITTED from the payload entirely (not defaulted to 0) rather than invented — that omission is itself the disclosed lossy behavior.",
2612
+ ],
2613
+ provenance: [
2614
+ {
2615
+ kind: "oci",
2616
+ ref: "ghcr.io/pdp-connect/connector/shopify:0.2.0",
2617
+ digest: "sha256:167cff474a653ad7bce036c973a78adcfc3a3e8e73925a6e24c74961f979ac0e",
2618
+ path: "declarations/shopify.collection-profile.json#streams[name=orders]",
2619
+ },
2620
+ ],
2621
+ project(records, options) {
2622
+ const guard = requireStreams("shop.orders", options, ["orders"]);
2623
+ if (guard) {
2624
+ return guard;
2625
+ }
2626
+ const items = byStream(records, "orders");
2627
+ // orderNumber: legacy's own value is String(orderObj.id)
2628
+ // (shop-playwright.js:174) on its primary extraction path — a real,
2629
+ // derivable transform of the same id PDPP carries, not an invented
2630
+ // field. placedAt/total/itemCount: omitted (not defaulted) when the PDPP
2631
+ // source value is absent — see the `lossy` entry above (N-S1). Only
2632
+ // `id` is required per item by the pinned schema, so omitting these is
2633
+ // schema-valid.
2634
+ return {
2635
+ ok: true,
2636
+ payload: {
2637
+ orders: items.map((o) => ({
2638
+ id: orEmptyString(o.id),
2639
+ orderNumber: orEmptyString(o.id),
2640
+ ...(typeof o.order_date === "string" &&
2641
+ isValidIso8601DateTime(o.order_date)
2642
+ ? { placedAt: o.order_date }
2643
+ : {}),
2644
+ merchantName: orEmptyString(o.merchant_name),
2645
+ ...(typeof o.total_cents === "number"
2646
+ ? { total: o.total_cents / 100 }
2647
+ : {}),
2648
+ currency: orEmptyString(o.currency),
2649
+ status: orEmptyString(o.status),
2650
+ ...(typeof o.item_count === "number"
2651
+ ? { itemCount: o.item_count }
2652
+ : {}),
2653
+ ...(Array.isArray(o.line_item_titles) &&
2654
+ o.line_item_titles.every((title) => typeof title === "string")
2655
+ ? { lineItemTitles: o.line_item_titles }
2656
+ : {}),
2657
+ ...(typeof o.detail_url === "string"
2658
+ ? { detailUrl: o.detail_url }
2659
+ : {}),
2660
+ })),
2661
+ total: items.length,
2662
+ },
2663
+ };
2664
+ },
2665
+ };
2666
+ // ---------------------------------------------------------------------------
2667
+ // Oura (activity and readiness)
2668
+ // declarations/oura.collection-profile.json (0.1.1, current OCI pin)
2669
+ // connector_id: https://registry.pdpp.dev/connectors/oura
2670
+ // ---------------------------------------------------------------------------
2671
+ const ouraActivity = {
2672
+ scope: "oura.activity",
2673
+ pdppSource: "https://registry.pdpp.dev/connectors/oura",
2674
+ pdppStreams: ["activity"],
2675
+ legacySchemaPath: "oura.activity.json",
2676
+ fieldsRead: {
2677
+ activity: [
2678
+ "id",
2679
+ "day",
2680
+ "score",
2681
+ "active_calories",
2682
+ "total_calories",
2683
+ "steps",
2684
+ "equivalent_walking_distance",
2685
+ "high_activity_time",
2686
+ "medium_activity_time",
2687
+ "low_activity_time",
2688
+ "sedentary_time",
2689
+ "resting_time",
2690
+ "inactivity_alerts",
2691
+ "contributors",
2692
+ ],
2693
+ },
2694
+ primaryKey: { activity: ["id"] },
2695
+ lossy: [
2696
+ "timestamp: no source field; omitted (legacy schema makes it optional)",
2697
+ "target_calories: has no legacy field and is not projected",
2698
+ ],
2699
+ provenance: [
2700
+ {
2701
+ kind: "oci",
2702
+ ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
2703
+ digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
2704
+ path: "declarations/oura.collection-profile.json#streams[name=activity]",
2705
+ },
2706
+ ],
2707
+ project(records, options) {
2708
+ const guard = requireStreams("oura.activity", options, ["activity"]);
2709
+ if (guard)
2710
+ return guard;
2711
+ return {
2712
+ ok: true,
2713
+ payload: {
2714
+ days: byStream(records, "activity").map((r) => ({
2715
+ id: r.id,
2716
+ day: r.day,
2717
+ ...ifDefined(r, "score", "score"),
2718
+ ...ifDefined(r, "active_calories", "activeCalories"),
2719
+ ...ifDefined(r, "total_calories", "totalCalories"),
2720
+ ...ifDefined(r, "steps", "steps"),
2721
+ ...ifDefined(r, "equivalent_walking_distance", "equivalentWalkingDistance"),
2722
+ ...ifDefined(r, "high_activity_time", "highActivityTime"),
2723
+ ...ifDefined(r, "medium_activity_time", "mediumActivityTime"),
2724
+ ...ifDefined(r, "low_activity_time", "lowActivityTime"),
2725
+ ...ifDefined(r, "sedentary_time", "sedentaryTime"),
2726
+ ...ifDefined(r, "resting_time", "restingTime"),
2727
+ ...ifDefined(r, "inactivity_alerts", "inactivityAlerts"),
2728
+ ...(r.contributors && typeof r.contributors === "object"
2729
+ ? { contributors: r.contributors }
2730
+ : {}),
2731
+ })),
2732
+ },
2733
+ };
2734
+ },
2735
+ };
2736
+ const ouraReadiness = {
2737
+ scope: "oura.readiness",
2738
+ pdppSource: "https://registry.pdpp.dev/connectors/oura",
2739
+ pdppStreams: ["readiness"],
2740
+ legacySchemaPath: "oura.readiness.json",
2741
+ fieldsRead: {
2742
+ readiness: [
2743
+ "id",
2744
+ "day",
2745
+ "score",
2746
+ "temperature_deviation",
2747
+ "temperature_trend_deviation",
2748
+ "contributors",
2749
+ ],
2750
+ },
2751
+ primaryKey: { readiness: ["id"] },
2752
+ lossy: [
2753
+ "timestamp: no source field; omitted (legacy schema makes it optional)",
2754
+ ],
2755
+ provenance: [
2756
+ {
2757
+ kind: "oci",
2758
+ ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
2759
+ digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
2760
+ path: "declarations/oura.collection-profile.json#streams[name=readiness]",
2761
+ },
2762
+ ],
2763
+ project(records, options) {
2764
+ const guard = requireStreams("oura.readiness", options, ["readiness"]);
2765
+ if (guard)
2766
+ return guard;
2767
+ return {
2768
+ ok: true,
2769
+ payload: {
2770
+ days: byStream(records, "readiness").map((r) => ({
2771
+ id: r.id,
2772
+ day: r.day,
2773
+ ...ifDefined(r, "score", "score"),
2774
+ ...ifDefined(r, "temperature_deviation", "temperatureDeviation"),
2775
+ ...ifDefined(r, "temperature_trend_deviation", "temperatureTrendDeviation"),
2776
+ ...(r.contributors && typeof r.contributors === "object"
2777
+ ? { contributors: r.contributors }
2778
+ : {}),
2779
+ })),
2780
+ },
2781
+ };
2782
+ },
2783
+ };
2784
+ const ouraSleep = {
2785
+ scope: "oura.sleep",
2786
+ pdppSource: "https://registry.pdpp.dev/connectors/oura",
2787
+ pdppStreams: ["sleep"],
2788
+ legacySchemaPath: "oura.sleep.json",
2789
+ fieldsRead: {
2790
+ sleep: [
2791
+ "id",
2792
+ "day",
2793
+ "sleep_score",
2794
+ "contributors",
2795
+ "type",
2796
+ "bedtime_start",
2797
+ "bedtime_end",
2798
+ "total_sleep_duration",
2799
+ "time_in_bed",
2800
+ "deep_sleep_duration",
2801
+ "light_sleep_duration",
2802
+ "rem_sleep_duration",
2803
+ "efficiency",
2804
+ "latency",
2805
+ "average_heart_rate",
2806
+ "average_hrv",
2807
+ "lowest_heart_rate",
2808
+ "average_breath",
2809
+ "restless_periods",
2810
+ ],
2811
+ },
2812
+ primaryKey: { sleep: ["id"] },
2813
+ lossy: [
2814
+ "awakeTime: no declared PDPP field and is omitted",
2815
+ "dailyScores.timestamp: no source field and is omitted",
2816
+ "sleepPeriods.averageHrv: PDPP allows a non-integer number but legacy schema allows only integer or null; non-integer values are omitted",
2817
+ "sleepPeriods.type: unrecognized or null values are omitted because the legacy enum accepts only long_sleep, short_sleep or rest",
2818
+ ],
2819
+ provenance: [
2820
+ {
2821
+ kind: "oci",
2822
+ ref: "ghcr.io/pdp-connect/connector/oura:0.1.1",
2823
+ digest: "sha256:7e39080f5ca65b83f44911cb120f9b8fc5c696b874e6493b26c00f07172efcae",
2824
+ path: "declarations/oura.collection-profile.json#streams[name=sleep]",
2825
+ },
2826
+ ],
2827
+ project(records, options) {
2828
+ const guard = requireStreams("oura.sleep", options, ["sleep"]);
2829
+ if (guard)
2830
+ return guard;
2831
+ const rows = byStream(records, "sleep");
2832
+ const periods = rows.map((r) => ({
2833
+ id: r.id,
2834
+ day: r.day,
2835
+ ...(r.type === "long_sleep" ||
2836
+ r.type === "short_sleep" ||
2837
+ r.type === "rest"
2838
+ ? { type: r.type }
2839
+ : {}),
2840
+ ...ifDefined(r, "bedtime_start", "bedtimeStart"),
2841
+ ...ifDefined(r, "bedtime_end", "bedtimeEnd"),
2842
+ ...ifDefined(r, "total_sleep_duration", "totalSleepDuration"),
2843
+ ...ifDefined(r, "time_in_bed", "timeInBed"),
2844
+ ...ifDefined(r, "deep_sleep_duration", "deepSleepDuration"),
2845
+ ...ifDefined(r, "light_sleep_duration", "lightSleepDuration"),
2846
+ ...ifDefined(r, "rem_sleep_duration", "remSleepDuration"),
2847
+ ...ifDefined(r, "efficiency", "efficiency"),
2848
+ ...ifDefined(r, "latency", "latency"),
2849
+ ...ifDefined(r, "average_heart_rate", "averageHeartRate"),
2850
+ ...(r.average_hrv === null
2851
+ ? { averageHrv: null }
2852
+ : typeof r.average_hrv === "number" && Number.isInteger(r.average_hrv)
2853
+ ? { averageHrv: r.average_hrv }
2854
+ : {}),
2855
+ ...ifDefined(r, "lowest_heart_rate", "lowestHeartRate"),
2856
+ ...ifDefined(r, "average_breath", "averageBreath"),
2857
+ ...ifDefined(r, "restless_periods", "restlessPeriods"),
2858
+ }));
2859
+ return {
2860
+ ok: true,
2861
+ payload: {
2862
+ dailyScores: rows.map((r) => ({
2863
+ id: r.id,
2864
+ day: r.day,
2865
+ ...ifDefined(r, "sleep_score", "score"),
2866
+ ...(r.contributors && typeof r.contributors === "object"
2867
+ ? { contributors: r.contributors }
2868
+ : {}),
2869
+ })),
2870
+ sleepPeriods: periods,
2871
+ },
2872
+ };
2873
+ },
2874
+ };
2875
+ // ---------------------------------------------------------------------------
2876
+ // amazon
2877
+ // declarations/amazon.collection-profile.json, streams "orders" +
2878
+ // "order_items" (joined on order_items.order_id === orders.id).
2879
+ // connector_id: https://registry.pdpp.dev/connectors/amazon
2880
+ // ---------------------------------------------------------------------------
2881
+ const amazonOrders = {
2882
+ scope: "amazon.orders",
2883
+ pdppSource: "https://registry.pdpp.dev/connectors/amazon",
2884
+ pdppStreams: ["orders", "order_items"],
2885
+ legacySchemaPath: "amazon.orders.json",
2886
+ fieldsRead: {
2887
+ orders: ["id", "order_date", "order_total", "delivery_status"],
2888
+ order_items: ["order_id", "name", "url", "unit_price"],
2889
+ },
2890
+ primaryKey: { orders: ["id"], order_items: ["id"] },
2891
+ lossy: [
2892
+ "items[].quantity: no legacy field (legacy schema does not carry quantity at all; PDPP-side quantity is simply not projected)",
2893
+ "items[].price (N-S2): legacy's own connector ALWAYS emits the literal constant '' for this field (amazon-playwright.js:222, `price: ''` — not a fallback for a missing value, the connector never scrapes a real price here at all). This projection instead emits PDPP's real `unit_price` when present, so an app reading this field gets a populated value where legacy always gave it an empty string — a value change, not a gap-fill, and the comment at the call site below previously implied the two matched when they do not.",
2894
+ ],
2895
+ provenance: [
2896
+ {
2897
+ kind: "oci",
2898
+ ref: "ghcr.io/pdp-connect/connector/amazon:0.1.0",
2899
+ digest: "sha256:dfddeffbc11c53fa2577c71f45428b87ccb25a69175218621398342a22b415d4",
2900
+ path: "declarations/amazon.collection-profile.json#streams[name=orders,order_items]",
2901
+ },
2902
+ ],
2903
+ project(records, options) {
2904
+ const guard = requireStreams("amazon.orders", options, [
2905
+ "orders",
2906
+ "order_items",
2907
+ ]);
2908
+ if (guard) {
2909
+ return guard;
2910
+ }
2911
+ const orders = byStream(records, "orders");
2912
+ const items = byStream(records, "order_items");
2913
+ // orderDate: legacy scrapes "MonthName D, YYYY" out of card text via
2914
+ // regex (amazon-playwright.js:184-187); toLegacyLongDate reproduces
2915
+ // that exact text shape from PDPP's ISO order_date, not an ISO
2916
+ // passthrough. Item price: legacy ALWAYS emits the literal constant ''
2917
+ // here (amazon-playwright.js:222, `price: ''`) — it never scrapes a
2918
+ // real price for line items. This binding instead emits PDPP's real
2919
+ // `unit_price` when present, a disclosed value change, see `lossy`
2920
+ // (N-S2), not a match with legacy's own behavior.
2921
+ return {
2922
+ ok: true,
2923
+ payload: {
2924
+ orders: orders.map((o) => ({
2925
+ orderId: orEmptyString(o.id),
2926
+ orderDate: toLegacyLongDate(o.order_date),
2927
+ orderTotal: orEmptyString(o.order_total),
2928
+ deliveryStatus: orEmptyString(o.delivery_status),
2929
+ items: items
2930
+ .filter((i) => i.order_id === o.id)
2931
+ .map((i) => ({
2932
+ name: orEmptyString(i.name),
2933
+ url: orEmptyString(i.url),
2934
+ price: orEmptyString(i.unit_price),
2935
+ })),
2936
+ })),
2937
+ total: orders.length,
2938
+ },
2939
+ };
2940
+ },
2941
+ };
2942
+ // ---------------------------------------------------------------------------
2943
+ // H-E-B 0.5.3 is pinned to the published, signature-verified OCI artifact.
2944
+ // Product identity remains H-E-B's provider product_id.
2945
+ const hebSource = "https://registry.pdpp.dev/connectors/heb";
2946
+ const hebPublishedEvidence = {
2947
+ kind: "oci",
2948
+ ref: "ghcr.io/pdp-connect/connector/heb:0.5.3",
2949
+ digest: "sha256:9de1c9203453b3897305ca99dfe93755f0e5528b3e7e79d0a9e6c8313445c02a",
2950
+ path: "declarations/heb.collection-profile.json#streams[name=profile]",
2951
+ };
2952
+ const hebProfile = {
2953
+ scope: "heb.profile",
2954
+ pdppSource: hebSource,
2955
+ pdppStreams: ["profile"],
2956
+ legacySchemaPath: "heb.profile.json",
2957
+ fieldsRead: { profile: ["name", "email", "phone", "delivery_addresses"] },
2958
+ primaryKey: { profile: ["id"] },
2959
+ lossy: ["fetched_at is not part of the legacy profile payload."],
2960
+ provenance: [hebPublishedEvidence],
2961
+ project(records, options) {
2962
+ const guard = requireStreams("heb.profile", options, ["profile"]);
2963
+ if (guard)
2964
+ return guard;
2965
+ const profiles = byStream(records, "profile");
2966
+ if (profiles.length === 0)
2967
+ return {
2968
+ ok: false,
2969
+ error: { kind: "empty_singleton", scope: "heb.profile" },
2970
+ };
2971
+ if (profiles.length !== 1)
2972
+ return {
2973
+ ok: false,
2974
+ error: {
2975
+ kind: "incomplete_scope",
2976
+ scope: "heb.profile",
2977
+ reason: "profile stream did not contain exactly one record",
2978
+ },
2979
+ };
2980
+ const [p] = profiles;
2981
+ if ((p.name != null && typeof p.name !== "string") ||
2982
+ (p.email != null && typeof p.email !== "string") ||
2983
+ (p.phone != null && typeof p.phone !== "string")) {
2984
+ return {
2985
+ ok: false,
2986
+ error: {
2987
+ kind: "incomplete_scope",
2988
+ scope: "heb.profile",
2989
+ reason: "profile contains a malformed scalar value",
2990
+ },
2991
+ };
2992
+ }
2993
+ if (!Array.isArray(p.delivery_addresses)) {
2994
+ return {
2995
+ ok: false,
2996
+ error: {
2997
+ kind: "incomplete_scope",
2998
+ scope: "heb.profile",
2999
+ reason: "profile.delivery_addresses is unavailable",
3000
+ },
3001
+ };
3002
+ }
3003
+ if (Array.isArray(p.delivery_addresses) &&
3004
+ p.delivery_addresses.some((entry) => entry === null ||
3005
+ typeof entry !== "object" ||
3006
+ typeof entry.address !== "string" ||
3007
+ typeof entry.is_primary !== "boolean" ||
3008
+ !(typeof entry.label === "string" || entry.label === null))) {
3009
+ return {
3010
+ ok: false,
3011
+ error: {
3012
+ kind: "incomplete_scope",
3013
+ scope: "heb.profile",
3014
+ reason: "profile.delivery_addresses contains an unrepresentable address",
3015
+ },
3016
+ };
3017
+ }
3018
+ const deliveryAddresses = p.delivery_addresses.map((entry) => {
3019
+ const a = entry;
3020
+ return {
3021
+ address: a.address,
3022
+ isPrimary: a.is_primary,
3023
+ label: a.label,
3024
+ };
3025
+ });
3026
+ return {
3027
+ ok: true,
3028
+ payload: {
3029
+ name: typeof p.name === "string" ? p.name : null,
3030
+ email: typeof p.email === "string" ? p.email : null,
3031
+ phone: p.phone === undefined || p.phone === null ? null : p.phone,
3032
+ deliveryAddresses,
3033
+ },
3034
+ };
3035
+ },
3036
+ };
3037
+ // H-E-B emits one nutrition outcome for each distinct source order-item
3038
+ // product. Keep the source item stream in this projection so missing outcomes
3039
+ // cannot become a successful but incomplete legacy payload.
3040
+ const hebNutritionFields = [
3041
+ "product_id",
3042
+ "name",
3043
+ "source",
3044
+ "confidence",
3045
+ "calories",
3046
+ "protein_g",
3047
+ "carbs_g",
3048
+ "fat_g",
3049
+ "sodium_mg",
3050
+ "fiber_g",
3051
+ "sugar_g",
3052
+ "saturated_fat_g",
3053
+ "trans_fat_g",
3054
+ "cholesterol_mg",
3055
+ "added_sugar_g",
3056
+ "calcium_mg",
3057
+ "iron_mg",
3058
+ "potassium_mg",
3059
+ "vitamin_d_mcg",
3060
+ "serving_size",
3061
+ "servings_per_container",
3062
+ "upc",
3063
+ "ingredients",
3064
+ "allergens",
3065
+ "category",
3066
+ "highlights",
3067
+ "product_url",
3068
+ ];
3069
+ const hebNutrition = {
3070
+ scope: "heb.nutrition",
3071
+ pdppSource: hebSource,
3072
+ pdppStreams: ["nutrition", "orders", "order_items"],
3073
+ legacySchemaPath: "heb.nutrition.json",
3074
+ fieldsRead: {
3075
+ nutrition: hebNutritionFields,
3076
+ orders: [],
3077
+ order_items: ["product_id", "name", "product_url"],
3078
+ },
3079
+ primaryKey: { nutrition: ["id"], orders: ["id"], order_items: ["id"] },
3080
+ lossy: [
3081
+ "The legacy map key is the declared H-E-B product_id; no global UPC identity is inferred.",
3082
+ "fetched_at is omitted.",
3083
+ ],
3084
+ provenance: [
3085
+ {
3086
+ ...hebPublishedEvidence,
3087
+ path: "declarations/heb.collection-profile.json#streams[name=orders,order_items,nutrition]",
3088
+ },
3089
+ ],
3090
+ project(records, options) {
3091
+ const guard = requireStreams("heb.nutrition", options, [
3092
+ "nutrition",
3093
+ "orders",
3094
+ "order_items",
3095
+ ]);
3096
+ if (guard)
3097
+ return guard;
3098
+ const orderedProducts = new Map();
3099
+ for (const row of byStream(records, "order_items")) {
3100
+ // The source only creates nutrition targets for order lines with a
3101
+ // product_id, and deduplicates those targets by that id.
3102
+ if (!isNonEmptyString(row.product_id))
3103
+ continue;
3104
+ if (!isNonEmptyString(row.name) ||
3105
+ (row.product_url !== null && !isNonEmptyString(row.product_url))) {
3106
+ return {
3107
+ ok: false,
3108
+ error: {
3109
+ kind: "incomplete_scope",
3110
+ scope: "heb.nutrition",
3111
+ reason: "order item lacks its source product name or URL",
3112
+ },
3113
+ };
3114
+ }
3115
+ // The legacy browser connector deduplicates by product id and retains
3116
+ // the first observed order line as the product's name and URL.
3117
+ if (!orderedProducts.has(row.product_id)) {
3118
+ orderedProducts.set(row.product_id, {
3119
+ name: row.name,
3120
+ productUrl: row.product_url,
3121
+ });
3122
+ }
3123
+ }
3124
+ const items = Object.create(null);
3125
+ let found = 0, foundUSDA = 0, blocked = 0;
3126
+ for (const n of byStream(records, "nutrition")) {
3127
+ if (!isNonEmptyString(n.product_id) ||
3128
+ !orderedProducts.has(n.product_id) ||
3129
+ n.product_url !== orderedProducts.get(n.product_id)?.productUrl ||
3130
+ typeof n.source !== "string" ||
3131
+ ![
3132
+ "heb_product_page",
3133
+ "usda_fdc",
3134
+ "not_found",
3135
+ "error",
3136
+ "blocked",
3137
+ ].includes(n.source) ||
3138
+ (n.product_url === null && n.source !== "not_found")) {
3139
+ return {
3140
+ ok: false,
3141
+ error: {
3142
+ kind: "incomplete_scope",
3143
+ scope: "heb.nutrition",
3144
+ reason: "nutrition row lacks a product id, matching product URL, or valid observed outcome",
3145
+ },
3146
+ };
3147
+ }
3148
+ if (Object.hasOwn(items, n.product_id)) {
3149
+ return {
3150
+ ok: false,
3151
+ error: {
3152
+ kind: "incomplete_scope",
3153
+ scope: "heb.nutrition",
3154
+ reason: "duplicate nutrition product id",
3155
+ },
3156
+ };
3157
+ }
3158
+ const item = {
3159
+ name: orderedProducts.get(n.product_id).name,
3160
+ productUrl: n.product_url,
3161
+ source: n.source,
3162
+ };
3163
+ if (["high", "medium", "low"].includes(String(n.confidence))) {
3164
+ item.confidence = n.confidence;
3165
+ }
3166
+ for (const field of hebNutritionFields.slice(4, 19)) {
3167
+ if (n[field] === null || typeof n[field] === "number") {
3168
+ item[field] = n[field];
3169
+ }
3170
+ }
3171
+ if (n.serving_size === null || typeof n.serving_size === "string")
3172
+ item.servingSize = n.serving_size;
3173
+ if (n.servings_per_container === null ||
3174
+ typeof n.servings_per_container === "string")
3175
+ item.servingsPerContainer = n.servings_per_container;
3176
+ for (const field of [
3177
+ "upc",
3178
+ "ingredients",
3179
+ "allergens",
3180
+ "category",
3181
+ "highlights",
3182
+ ]) {
3183
+ if (n[field] !== undefined)
3184
+ item[field] = n[field];
3185
+ }
3186
+ // Preserve the legacy connector's productImageUrl() mapping. These URLs
3187
+ // are deterministic from the observed H-E-B product id; they are not
3188
+ // scraped or inferred from nutrition content.
3189
+ const paddedProductId = n.product_id.padStart(9, "0");
3190
+ item.images = {
3191
+ thumbnail: `https://images.heb.com/is/image/HEBGrocery/prd-small/${paddedProductId}.jpg`,
3192
+ full: `https://images.heb.com/is/image/HEBGrocery/${n.product_id}-1`,
3193
+ };
3194
+ items[n.product_id] = item;
3195
+ if (n.source === "heb_product_page")
3196
+ found++;
3197
+ if (n.source === "usda_fdc")
3198
+ foundUSDA++;
3199
+ if (n.source === "blocked")
3200
+ blocked++;
3201
+ }
3202
+ const total = Object.keys(items).length;
3203
+ if (total !== orderedProducts.size) {
3204
+ return {
3205
+ ok: false,
3206
+ error: {
3207
+ kind: "incomplete_scope",
3208
+ scope: "heb.nutrition",
3209
+ reason: "nutrition outcomes do not cover every ordered product",
3210
+ },
3211
+ };
3212
+ }
3213
+ return {
3214
+ ok: true,
3215
+ payload: {
3216
+ items,
3217
+ coverage: {
3218
+ total,
3219
+ found,
3220
+ foundUSDA,
3221
+ blocked,
3222
+ percentCovered: total
3223
+ ? Math.round(((found + foundUSDA) / total) * 100)
3224
+ : 0,
3225
+ },
3226
+ },
3227
+ };
3228
+ },
3229
+ };
3230
+ // Retained legacy H-E-B order projection, unchanged.
3231
+ // ---------------------------------------------------------------------------
3232
+ const hebOrders = {
3233
+ scope: "heb.orders",
3234
+ pdppSource: "https://registry.pdpp.dev/connectors/heb",
3235
+ pdppStreams: ["orders", "order_items"],
3236
+ legacySchemaPath: "heb.orders.json",
3237
+ fieldsRead: {
3238
+ orders: ["id", "order_date", "total_cents", "item_count", "status"],
3239
+ order_items: [
3240
+ "order_id",
3241
+ "name",
3242
+ "product_id",
3243
+ "product_url",
3244
+ "image_url",
3245
+ "quantity",
3246
+ "line_total_cents",
3247
+ ],
3248
+ },
3249
+ primaryKey: { orders: ["id"], order_items: ["id"] },
3250
+ lossy: [
3251
+ "orderUrl and address: the signed profile has no equivalent fields; fulfillment_location is not treated as an address.",
3252
+ "status_code, store_name, timeslot_start/end, unfulfilled_count, fulfillment_method, and fetched_at are not represented by the legacy schema.",
3253
+ "total and item price: derived from declared integer cents when present; omitted when absent. No currency or decimal text is guessed.",
3254
+ "the retained collector emitted only product-linked named lines; a source line without either field cannot be safely counted as complete and fails this scope.",
3255
+ "quantity: a declared numeric quantity is converted to its decimal string form to match the legacy connector output type.",
3256
+ ],
3257
+ provenance: [
3258
+ {
3259
+ ...hebPublishedEvidence,
3260
+ path: "declarations/heb.collection-profile.json#streams[name=orders,order_items]",
3261
+ },
3262
+ ],
3263
+ project(records, options) {
3264
+ const guard = requireStreams("heb.orders", options, [
3265
+ "orders",
3266
+ "order_items",
3267
+ ]);
3268
+ if (guard)
3269
+ return guard;
3270
+ const sourceItems = byStream(records, "order_items");
3271
+ const sourceOrderIds = new Set(byStream(records, "orders").map((order) => order.id));
3272
+ let skippedItems = 0;
3273
+ let skippedOrders = 0;
3274
+ let orderItemsShortOfDeclaredCount = false;
3275
+ const missingFields = new Set();
3276
+ if (sourceItems.some((item) => !sourceOrderIds.has(item.order_id))) {
3277
+ return {
3278
+ ok: false,
3279
+ error: {
3280
+ kind: "incomplete_scope",
3281
+ scope: "heb.orders",
3282
+ reason: "order_items.order_id has no matching order",
3283
+ },
3284
+ };
3285
+ }
3286
+ const orders = byStream(records, "orders").flatMap((order) => {
3287
+ if (!isNonEmptyString(order.id)) {
3288
+ skippedOrders += 1;
3289
+ missingFields.add("orders.id");
3290
+ return [];
3291
+ }
3292
+ const items = sourceItems
3293
+ .filter((item) => item.order_id === order.id)
3294
+ .flatMap((item) => {
3295
+ if (!isNonEmptyString(item.name) ||
3296
+ !isNonEmptyString(item.product_id)) {
3297
+ skippedItems += 1;
3298
+ if (!isNonEmptyString(item.name))
3299
+ missingFields.add("order_items.name");
3300
+ if (!isNonEmptyString(item.product_id)) {
3301
+ missingFields.add("order_items.product_id");
3302
+ }
3303
+ return [];
3304
+ }
3305
+ const quantity = typeof item.quantity === "number" && Number.isFinite(item.quantity)
3306
+ ? String(item.quantity)
3307
+ : null;
3308
+ const price = typeof item.line_total_cents === "number"
3309
+ ? item.line_total_cents / 100
3310
+ : undefined;
3311
+ return [
3312
+ {
3313
+ name: item.name,
3314
+ productId: item.product_id,
3315
+ productUrl: typeof item.product_url === "string" ? item.product_url : null,
3316
+ imageUrl: typeof item.image_url === "string" ? item.image_url : null,
3317
+ quantity,
3318
+ ...(price === undefined ? {} : { price }),
3319
+ },
3320
+ ];
3321
+ });
3322
+ if (typeof order.item_count === "number" &&
3323
+ Number.isInteger(order.item_count) &&
3324
+ order.item_count > items.length) {
3325
+ orderItemsShortOfDeclaredCount = true;
3326
+ }
3327
+ return [
3328
+ {
3329
+ orderId: order.id,
3330
+ items,
3331
+ orderDate: toLegacyLongDate(order.order_date),
3332
+ ...(typeof order.total_cents === "number"
3333
+ ? { total: order.total_cents / 100 }
3334
+ : {}),
3335
+ ...(typeof order.item_count === "number"
3336
+ ? { itemCount: order.item_count }
3337
+ : {}),
3338
+ ...(typeof order.status === "string" || order.status === null
3339
+ ? { status: order.status }
3340
+ : {}),
3341
+ },
3342
+ ];
3343
+ });
3344
+ const diagnostics = skippedItems + skippedOrders > 0
3345
+ ? [
3346
+ {
3347
+ kind: "records_skipped",
3348
+ scope: "heb.orders",
3349
+ count: skippedItems + skippedOrders,
3350
+ missingFields: [...missingFields],
3351
+ },
3352
+ ]
3353
+ : undefined;
3354
+ if (diagnostics) {
3355
+ return {
3356
+ ok: false,
3357
+ error: {
3358
+ kind: "incomplete_scope",
3359
+ scope: "heb.orders",
3360
+ reason: `Cannot preserve every valid order line: ${[...missingFields].join(", ")}`,
3361
+ },
3362
+ };
3363
+ }
3364
+ if (orderItemsShortOfDeclaredCount) {
3365
+ return {
3366
+ ok: false,
3367
+ error: {
3368
+ kind: "incomplete_scope",
3369
+ scope: "heb.orders",
3370
+ reason: "order_items are short of the count H-E-B reported for an order",
3371
+ },
3372
+ };
3373
+ }
3374
+ return {
3375
+ ok: true,
3376
+ payload: {
3377
+ orders,
3378
+ totalOrders: orders.length,
3379
+ totalItems: orders.reduce((total, order) => total + order.items.length, 0),
3380
+ },
3381
+ ...(diagnostics ? { diagnostics } : {}),
3382
+ };
3383
+ },
3384
+ };
3385
+ // Whole Foods 0.3.0 is the published, signature-verified OCI artifact.
3386
+ const wholefoodsSource = "https://registry.pdpp.dev/connectors/wholefoods";
3387
+ const wholefoodsEvidence = {
3388
+ kind: "oci",
3389
+ ref: "ghcr.io/pdp-connect/connector/wholefoods:0.3.0",
3390
+ digest: "sha256:873e287a153673bb9b96cae87dfe64e36730e18b30ea2e5c1731bdcff8d4432c",
3391
+ path: "declarations/wholefoods.collection-profile.json",
3392
+ };
3393
+ const wholefoodsProfile = {
3394
+ scope: "wholefoods.profile",
3395
+ pdppSource: wholefoodsSource,
3396
+ pdppStreams: ["profile"],
3397
+ legacySchemaPath: "wholefoods.profile.json",
3398
+ fieldsRead: { profile: ["name", "email"] },
3399
+ primaryKey: { profile: ["id"] },
3400
+ lossy: [
3401
+ "The legacy schema has no account id field; the Amazon customer id is not emitted.",
3402
+ ],
3403
+ provenance: [
3404
+ {
3405
+ ...wholefoodsEvidence,
3406
+ path: "declarations/wholefoods.collection-profile.json#streams[name=profile]",
3407
+ },
3408
+ ],
3409
+ project(records, options) {
3410
+ const guard = requireStreams("wholefoods.profile", options, ["profile"]);
3411
+ if (guard)
3412
+ return guard;
3413
+ const profiles = byStream(records, "profile");
3414
+ if (profiles.length === 0)
3415
+ return {
3416
+ ok: false,
3417
+ error: { kind: "empty_singleton", scope: "wholefoods.profile" },
3418
+ };
3419
+ if (profiles.length !== 1)
3420
+ return {
3421
+ ok: false,
3422
+ error: {
3423
+ kind: "incomplete_scope",
3424
+ scope: "wholefoods.profile",
3425
+ reason: "profile stream did not contain exactly one record",
3426
+ },
3427
+ };
3428
+ const [p] = profiles;
3429
+ if ((p.name != null && typeof p.name !== "string") ||
3430
+ (p.email != null && typeof p.email !== "string")) {
3431
+ return {
3432
+ ok: false,
3433
+ error: {
3434
+ kind: "incomplete_scope",
3435
+ scope: "wholefoods.profile",
3436
+ reason: "profile contains a malformed scalar value",
3437
+ },
3438
+ };
3439
+ }
3440
+ return {
3441
+ ok: true,
3442
+ payload: {
3443
+ name: typeof p.name === "string" ? p.name : null,
3444
+ email: typeof p.email === "string" ? p.email : null,
3445
+ },
3446
+ };
3447
+ },
3448
+ };
3449
+ const wholefoodsOrders = {
3450
+ scope: "wholefoods.orders",
3451
+ pdppSource: wholefoodsSource,
3452
+ pdppStreams: ["orders", "order_items"],
3453
+ legacySchemaPath: "wholefoods.orders.json",
3454
+ fieldsRead: {
3455
+ orders: [
3456
+ "id",
3457
+ "order_date",
3458
+ "order_url",
3459
+ "status",
3460
+ "total_cents",
3461
+ "item_count",
3462
+ ],
3463
+ order_items: [
3464
+ "order_id",
3465
+ "name",
3466
+ "product_id",
3467
+ "product_url",
3468
+ "image_url",
3469
+ "quantity",
3470
+ "unit_price_cents",
3471
+ ],
3472
+ },
3473
+ primaryKey: { orders: ["id"], order_items: ["id"] },
3474
+ lossy: [
3475
+ "The retained collector emitted only product-linked named lines; a source line without either field fails this scope as incomplete.",
3476
+ "Product identity is the source Amazon ASIN in product_id.",
3477
+ ],
3478
+ provenance: [
3479
+ {
3480
+ ...wholefoodsEvidence,
3481
+ path: "declarations/wholefoods.collection-profile.json#streams[name=orders,order_items]",
3482
+ },
3483
+ ],
3484
+ project(records, options) {
3485
+ const guard = requireStreams("wholefoods.orders", options, [
3486
+ "orders",
3487
+ "order_items",
3488
+ ]);
3489
+ if (guard)
3490
+ return guard;
3491
+ const sourceItems = byStream(records, "order_items");
3492
+ const sourceOrders = byStream(records, "orders");
3493
+ const sourceOrderIds = new Set(sourceOrders.map((order) => order.id));
3494
+ if (sourceOrderIds.size !== sourceOrders.length ||
3495
+ sourceOrders.some((order) => !isNonEmptyString(order.id))) {
3496
+ return {
3497
+ ok: false,
3498
+ error: {
3499
+ kind: "incomplete_scope",
3500
+ scope: "wholefoods.orders",
3501
+ reason: "orders contain a missing or duplicate id",
3502
+ },
3503
+ };
3504
+ }
3505
+ const unrepresentableItem = sourceItems.some((item) => !sourceOrderIds.has(item.order_id) ||
3506
+ !isNonEmptyString(item.name) ||
3507
+ !isNonEmptyString(item.product_id));
3508
+ if (unrepresentableItem) {
3509
+ return {
3510
+ ok: false,
3511
+ error: {
3512
+ kind: "incomplete_scope",
3513
+ scope: "wholefoods.orders",
3514
+ reason: "Cannot preserve every valid order line: order_items.name or order_items.product_id is missing",
3515
+ },
3516
+ };
3517
+ }
3518
+ if (sourceOrders.some((order) => typeof order.item_count !== "number" ||
3519
+ !Number.isSafeInteger(order.item_count) ||
3520
+ order.item_count < 0 ||
3521
+ sourceItems.filter((item) => item.order_id === order.id).length !==
3522
+ order.item_count)) {
3523
+ return {
3524
+ ok: false,
3525
+ error: {
3526
+ kind: "incomplete_scope",
3527
+ scope: "wholefoods.orders",
3528
+ reason: "order item count is missing or does not match the observed lines",
3529
+ },
3530
+ };
3531
+ }
3532
+ const orders = byStream(records, "orders").flatMap((o) => {
3533
+ if (!isNonEmptyString(o.id))
3534
+ return [];
3535
+ const items = sourceItems
3536
+ .filter((i) => i.order_id === o.id)
3537
+ .flatMap((i) => {
3538
+ if (!isNonEmptyString(i.name) || !isNonEmptyString(i.product_id))
3539
+ return [];
3540
+ return [
3541
+ {
3542
+ name: i.name,
3543
+ productId: i.product_id,
3544
+ ...(typeof i.product_url === "string"
3545
+ ? { productUrl: i.product_url }
3546
+ : {}),
3547
+ ...(typeof i.image_url === "string"
3548
+ ? { imageUrl: i.image_url }
3549
+ : {}),
3550
+ quantity: typeof i.quantity === "number" ? String(i.quantity) : null,
3551
+ price: typeof i.unit_price_cents === "number"
3552
+ ? i.unit_price_cents / 100
3553
+ : null,
3554
+ },
3555
+ ];
3556
+ });
3557
+ return [
3558
+ {
3559
+ orderId: o.id,
3560
+ items,
3561
+ ...(typeof o.order_url === "string" ? { orderUrl: o.order_url } : {}),
3562
+ ...(typeof o.order_date === "string" || o.order_date === null
3563
+ ? { orderDate: o.order_date }
3564
+ : {}),
3565
+ // The legacy Whole Foods collector labeled every observed order Completed;
3566
+ // source 0.3.0 correctly reports status as unknown (null).
3567
+ ...(o.status === null
3568
+ ? { status: "Completed" }
3569
+ : typeof o.status === "string"
3570
+ ? { status: o.status }
3571
+ : {}),
3572
+ ...(typeof o.total_cents === "number"
3573
+ ? { total: o.total_cents / 100 }
3574
+ : {}),
3575
+ ...(typeof o.item_count === "number"
3576
+ ? { itemCount: o.item_count }
3577
+ : {}),
3578
+ },
3579
+ ];
3580
+ });
3581
+ return {
3582
+ ok: true,
3583
+ payload: {
3584
+ orders,
3585
+ totalOrders: orders.length,
3586
+ totalItems: orders.reduce((sum, o) => sum + o.items.length, 0),
3587
+ },
3588
+ };
3589
+ },
3590
+ };
3591
+ const wholefoodsNutritionFields = [
3592
+ "product_id",
3593
+ "name",
3594
+ "source",
3595
+ "confidence",
3596
+ "calories",
3597
+ "protein_g",
3598
+ "carbs_g",
3599
+ "fat_g",
3600
+ "sodium_mg",
3601
+ "fiber_g",
3602
+ "sugar_g",
3603
+ "serving_size",
3604
+ "servings_per_container",
3605
+ ];
3606
+ const wholefoodsNutrition = {
3607
+ scope: "wholefoods.nutrition",
3608
+ pdppSource: wholefoodsSource,
3609
+ pdppStreams: ["nutrition", "order_items"],
3610
+ legacySchemaPath: "wholefoods.nutrition.json",
3611
+ fieldsRead: {
3612
+ nutrition: wholefoodsNutritionFields,
3613
+ order_items: ["product_id", "name", "product_url", "image_url"],
3614
+ },
3615
+ primaryKey: { nutrition: ["product_id"], order_items: ["id"] },
3616
+ lossy: [
3617
+ "ingredients and allergens are not collected by the current nutrition stream.",
3618
+ "servingsPerContainer is converted from the declared number to the legacy string field.",
3619
+ ],
3620
+ provenance: [
3621
+ {
3622
+ ...wholefoodsEvidence,
3623
+ path: "declarations/wholefoods.collection-profile.json#streams[name=nutrition]",
3624
+ },
3625
+ ],
3626
+ project(records, options) {
3627
+ const guard = requireStreams("wholefoods.nutrition", options, [
3628
+ "nutrition",
3629
+ "order_items",
3630
+ ]);
3631
+ if (guard)
3632
+ return guard;
3633
+ const items = Object.create(null);
3634
+ const orderedProducts = new Map();
3635
+ for (const row of byStream(records, "order_items")) {
3636
+ if (!isNonEmptyString(row.product_id) || typeof row.name !== "string")
3637
+ return {
3638
+ ok: false,
3639
+ error: {
3640
+ kind: "incomplete_scope",
3641
+ scope: "wholefoods.nutrition",
3642
+ reason: "order item lacks a source ASIN or name",
3643
+ },
3644
+ };
3645
+ orderedProducts.set(row.product_id, {
3646
+ name: row.name,
3647
+ ...(typeof row.product_url === "string"
3648
+ ? { productUrl: row.product_url }
3649
+ : {}),
3650
+ ...(typeof row.image_url === "string"
3651
+ ? { imageUrl: row.image_url }
3652
+ : {}),
3653
+ });
3654
+ }
3655
+ let found = 0, foundUSDA = 0, blocked = 0;
3656
+ const seenNutrition = new Set();
3657
+ for (const n of byStream(records, "nutrition")) {
3658
+ if (!isNonEmptyString(n.product_id) ||
3659
+ typeof n.source !== "string" ||
3660
+ ![
3661
+ "wholefoods_product_page",
3662
+ "usda_fdc",
3663
+ "not_found",
3664
+ "error",
3665
+ "blocked",
3666
+ ].includes(n.source) ||
3667
+ !orderedProducts.has(n.product_id) ||
3668
+ seenNutrition.has(n.product_id))
3669
+ return {
3670
+ ok: false,
3671
+ error: {
3672
+ kind: "incomplete_scope",
3673
+ scope: "wholefoods.nutrition",
3674
+ reason: "nutrition row has an invalid, duplicate, or unordered product outcome",
3675
+ },
3676
+ };
3677
+ seenNutrition.add(n.product_id);
3678
+ const ordered = orderedProducts.get(n.product_id);
3679
+ const name = typeof n.name === "string" ? n.name : ordered?.name;
3680
+ if (typeof name !== "string")
3681
+ return {
3682
+ ok: false,
3683
+ error: {
3684
+ kind: "incomplete_scope",
3685
+ scope: "wholefoods.nutrition",
3686
+ reason: "nutrition product name is unavailable",
3687
+ },
3688
+ };
3689
+ const item = { name, source: n.source };
3690
+ if (ordered?.productUrl !== undefined)
3691
+ item.productUrl = ordered.productUrl;
3692
+ if (ordered?.imageUrl !== undefined)
3693
+ item.images = ordered.imageUrl;
3694
+ if (["high", "medium", "low"].includes(String(n.confidence))) {
3695
+ item.confidence = n.confidence;
3696
+ }
3697
+ for (const field of [
3698
+ "calories",
3699
+ "protein_g",
3700
+ "carbs_g",
3701
+ "fat_g",
3702
+ "sodium_mg",
3703
+ "fiber_g",
3704
+ "sugar_g",
3705
+ ]) {
3706
+ if (n[field] === null || typeof n[field] === "number") {
3707
+ item[field] = n[field];
3708
+ }
3709
+ }
3710
+ if (n.serving_size === null || typeof n.serving_size === "string")
3711
+ item.servingSize = n.serving_size;
3712
+ if (typeof n.servings_per_container === "number")
3713
+ item.servingsPerContainer = String(n.servings_per_container);
3714
+ else if (n.servings_per_container === null)
3715
+ item.servingsPerContainer = null;
3716
+ items[n.product_id] = item;
3717
+ if (n.source === "wholefoods_product_page")
3718
+ found++;
3719
+ if (n.source === "usda_fdc")
3720
+ foundUSDA++;
3721
+ if (n.source === "blocked")
3722
+ blocked++;
3723
+ }
3724
+ const total = orderedProducts.size;
3725
+ if (seenNutrition.size !== total)
3726
+ return {
3727
+ ok: false,
3728
+ error: {
3729
+ kind: "incomplete_scope",
3730
+ scope: "wholefoods.nutrition",
3731
+ reason: "nutrition outcomes do not cover every ordered product",
3732
+ },
3733
+ };
3734
+ return {
3735
+ ok: true,
3736
+ payload: {
3737
+ items,
3738
+ coverage: {
3739
+ total,
3740
+ found,
3741
+ foundUSDA,
3742
+ blocked,
3743
+ percentCovered: total
3744
+ ? Math.round(((found + foundUSDA) / total) * 100)
3745
+ : 0,
3746
+ },
3747
+ },
3748
+ };
3749
+ },
3750
+ };
3751
+ // amazon.profile is a documented GAP: no `profile` stream exists on the
3752
+ // amazon collection-profile.json at all (verified: exactly 2 streams,
3753
+ // orders and order_items) — legacy's `name`/`isPrime` have no PDPP source.
3754
+ // iCloud Notes is bound to the signed 0.1.0 collection profile. Desktop
3755
+ // admission and ingest projection are separate gates.
3756
+ const icloudNoteFolders = {
3757
+ scope: "icloud_notes.folders",
3758
+ pdppSource: "https://registry.pdpp.dev/connectors/icloud-notes",
3759
+ pdppStreams: ["folders"],
3760
+ legacySchemaPath: "icloud_notes.folders.json",
3761
+ fieldsRead: { folders: ["id", "name"] },
3762
+ primaryKey: { folders: ["id"] },
3763
+ lossy: [],
3764
+ provenance: [
3765
+ {
3766
+ kind: "oci",
3767
+ ref: "ghcr.io/pdp-connect/connector/icloud-notes:0.1.0",
3768
+ digest: "sha256:fe5497bb79b0d567c30790cc71b1e591b955fe474725679b996441bd70c09944",
3769
+ path: "declarations/icloud-notes.collection-profile.json#streams[name=folders]",
3770
+ },
3771
+ ],
3772
+ project(records, options) {
3773
+ const guard = requireStreams("icloud_notes.folders", options, ["folders"]);
3774
+ if (guard)
3775
+ return guard;
3776
+ const folders = byStream(records, "folders");
3777
+ return {
3778
+ ok: true,
3779
+ payload: {
3780
+ folders: folders.map((folder) => ({
3781
+ recordName: orEmptyString(folder.id),
3782
+ title: orEmptyString(folder.name),
3783
+ })),
3784
+ total: folders.length,
3785
+ },
3786
+ };
3787
+ },
3788
+ };
3789
+ const icloudNotes = {
3790
+ scope: "icloud_notes.notes",
3791
+ pdppSource: "https://registry.pdpp.dev/connectors/icloud-notes",
3792
+ pdppStreams: ["notes", "folders"],
3793
+ legacySchemaPath: "icloud_notes.notes.json",
3794
+ fieldsRead: {
3795
+ notes: [
3796
+ "id",
3797
+ "title",
3798
+ "snippet",
3799
+ "folder_id",
3800
+ "is_pinned",
3801
+ "created_at",
3802
+ "modified_at",
3803
+ "has_attachments",
3804
+ "text_content",
3805
+ ],
3806
+ folders: ["id", "name"],
3807
+ },
3808
+ primaryKey: { notes: ["id"], folders: ["id"] },
3809
+ lossy: [
3810
+ "userName is always null because the declaration has no account/profile stream for the legacy fullName source.",
3811
+ "folder uses the legacy display-name shape; unresolved folder references fall back to folder_id.",
3812
+ ],
3813
+ provenance: [
3814
+ {
3815
+ kind: "oci",
3816
+ ref: "ghcr.io/pdp-connect/connector/icloud-notes:0.1.0",
3817
+ digest: "sha256:fe5497bb79b0d567c30790cc71b1e591b955fe474725679b996441bd70c09944",
3818
+ path: "declarations/icloud-notes.collection-profile.json#streams[name=notes,folders]",
3819
+ },
3820
+ ],
3821
+ project(records, options) {
3822
+ const guard = requireStreams("icloud_notes.notes", options, [
3823
+ "notes",
3824
+ "folders",
3825
+ ]);
3826
+ if (guard)
3827
+ return guard;
3828
+ const folders = new Map();
3829
+ for (const folder of byStream(records, "folders")) {
3830
+ if (typeof folder.id !== "string" ||
3831
+ folder.id.length === 0 ||
3832
+ typeof folder.name !== "string" ||
3833
+ folder.name.length === 0) {
3834
+ return {
3835
+ ok: false,
3836
+ error: {
3837
+ kind: "invalid_value",
3838
+ scope: "icloud_notes.notes",
3839
+ reason: "iCloud Notes folder is missing a declared required field",
3840
+ },
3841
+ };
3842
+ }
3843
+ folders.set(folder.id, folder.name);
3844
+ }
3845
+ const notes = byStream(records, "notes").map((note) => {
3846
+ if (typeof note.id !== "string" || note.id.length === 0)
3847
+ return undefined;
3848
+ const optionalString = (field) => {
3849
+ const value = note[field];
3850
+ return value == null || typeof value === "string"
3851
+ ? (value ?? null)
3852
+ : undefined;
3853
+ };
3854
+ const title = optionalString("title");
3855
+ const snippet = optionalString("snippet");
3856
+ const folderId = optionalString("folder_id");
3857
+ const createdDate = optionalString("created_at");
3858
+ const modifiedDate = optionalString("modified_at");
3859
+ const textContent = optionalString("text_content");
3860
+ if (title === undefined ||
3861
+ snippet === undefined ||
3862
+ folderId === undefined ||
3863
+ createdDate === undefined ||
3864
+ modifiedDate === undefined ||
3865
+ textContent === undefined ||
3866
+ typeof note.is_pinned !== "boolean" ||
3867
+ typeof note.has_attachments !== "boolean")
3868
+ return undefined;
3869
+ return {
3870
+ recordName: note.id,
3871
+ title,
3872
+ snippet,
3873
+ folder: folderId === null ? null : (folders.get(folderId) ?? folderId),
3874
+ isPinned: note.is_pinned,
3875
+ createdDate,
3876
+ modifiedDate,
3877
+ hasAttachments: note.has_attachments,
3878
+ textContent,
3879
+ };
3880
+ });
3881
+ if (notes.some((note) => note === undefined)) {
3882
+ return {
3883
+ ok: false,
3884
+ error: {
3885
+ kind: "invalid_value",
3886
+ scope: "icloud_notes.notes",
3887
+ reason: "iCloud Notes contains a record that does not match its declared schema",
3888
+ },
3889
+ };
3890
+ }
3891
+ return {
3892
+ ok: true,
3893
+ payload: { notes, total: notes.length, userName: null },
3894
+ };
3895
+ },
3896
+ };
3897
+ export const LEGACY_SCOPE_BINDINGS = new Map(Object.entries({
3898
+ "instagram.profile": instagramProfile,
3899
+ "instagram.posts": instagramPosts,
3900
+ "instagram.following": instagramFollowing,
3901
+ "instagram.ads": instagramAds,
3902
+ "github.repositories": githubRepositories,
3903
+ "github.starred": githubStarred,
3904
+ "github.history": githubHistory,
3905
+ "github.profile": githubProfile,
3906
+ "github.events": githubEvents,
3907
+ "github.contributions": githubContributions,
3908
+ "chatgpt.memories": chatgptMemories,
3909
+ "chatgpt.conversations": chatgptConversations,
3910
+ "linkedin.profile": linkedinProfile,
3911
+ "linkedin.connections": linkedinConnections,
3912
+ "linkedin.experience": linkedinExperience,
3913
+ "linkedin.education": linkedinEducation,
3914
+ "linkedin.skills": linkedinSkills,
3915
+ "linkedin.languages": linkedinLanguages,
3916
+ "spotify.profile": spotifyProfile,
3917
+ "spotify.savedTracks": spotifySavedTracks,
3918
+ "spotify.playlists": spotifyPlaylists,
3919
+ "shop.orders": shopOrders,
3920
+ "amazon.orders": amazonOrders,
3921
+ "heb.orders": hebOrders,
3922
+ "heb.profile": hebProfile,
3923
+ "heb.nutrition": hebNutrition,
3924
+ "wholefoods.profile": wholefoodsProfile,
3925
+ "wholefoods.orders": wholefoodsOrders,
3926
+ "wholefoods.nutrition": wholefoodsNutrition,
3927
+ "oura.activity": ouraActivity,
3928
+ "oura.readiness": ouraReadiness,
3929
+ "oura.sleep": ouraSleep,
3930
+ "icloud_notes.notes": icloudNotes,
3931
+ "icloud_notes.folders": icloudNoteFolders,
3932
+ "youtube.profile": youtubeProfile,
3933
+ "youtube.subscriptions": youtubeSubscriptions,
3934
+ "youtube.playlists": youtubePlaylists,
3935
+ "youtube.playlistItems": youtubePlaylistItems,
3936
+ "youtube.likes": youtubeLikes,
3937
+ "youtube.watchLater": youtubeWatchLater,
3938
+ "youtube.history": youtubeHistory,
3939
+ "claude.conversations": claudeConversations,
3940
+ "claude.projects": claudeProjects,
3941
+ }));
3942
+ /** Known legacy contracts checked against signed declarations but not
3943
+ * projectable without fabricating fields or changing the retained contract. */
3944
+ export const LEGACY_SCOPE_GAPS = new Map([]);
3945
+ //# sourceMappingURL=bindings.js.map