@opendatalabs/personal-server-ts-core 1.33.8 → 1.34.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 (330) hide show
  1. package/dist/additions/budget.d.ts +28 -0
  2. package/dist/additions/budget.d.ts.map +1 -0
  3. package/dist/additions/budget.js +119 -0
  4. package/dist/additions/budget.js.map +1 -0
  5. package/dist/additions/durable.d.ts +64 -0
  6. package/dist/additions/durable.d.ts.map +1 -0
  7. package/dist/additions/durable.js +591 -0
  8. package/dist/additions/durable.js.map +1 -0
  9. package/dist/additions/first-added.d.ts +32 -48
  10. package/dist/additions/first-added.d.ts.map +1 -1
  11. package/dist/additions/first-added.js +68 -123
  12. package/dist/additions/first-added.js.map +1 -1
  13. package/dist/additions/index.d.ts +3 -0
  14. package/dist/additions/index.d.ts.map +1 -1
  15. package/dist/additions/index.js +3 -0
  16. package/dist/additions/index.js.map +1 -1
  17. package/dist/additions/ledger-store.js +1 -1
  18. package/dist/additions/record-rules.d.ts.map +1 -1
  19. package/dist/additions/record-rules.js +7 -0
  20. package/dist/additions/record-rules.js.map +1 -1
  21. package/dist/additions/summary.d.ts +88 -1
  22. package/dist/additions/summary.d.ts.map +1 -1
  23. package/dist/additions/summary.js +173 -8
  24. package/dist/additions/summary.js.map +1 -1
  25. package/dist/additions/version-inventory.d.ts +80 -0
  26. package/dist/additions/version-inventory.d.ts.map +1 -0
  27. package/dist/additions/version-inventory.js +232 -0
  28. package/dist/additions/version-inventory.js.map +1 -0
  29. package/dist/api/index.d.ts +70 -2
  30. package/dist/api/index.d.ts.map +1 -1
  31. package/dist/api/index.js +506 -79
  32. package/dist/api/index.js.map +1 -1
  33. package/dist/auth/index.d.ts +1 -1
  34. package/dist/auth/index.d.ts.map +1 -1
  35. package/dist/auth/index.js +1 -1
  36. package/dist/auth/index.js.map +1 -1
  37. package/dist/auth/request.d.ts +6 -0
  38. package/dist/auth/request.d.ts.map +1 -1
  39. package/dist/auth/request.js +66 -4
  40. package/dist/auth/request.js.map +1 -1
  41. package/dist/contracts/data.d.ts +54 -8
  42. package/dist/contracts/data.d.ts.map +1 -1
  43. package/dist/contracts/data.js +178 -35
  44. package/dist/contracts/data.js.map +1 -1
  45. package/dist/contracts/index.d.ts +1 -1
  46. package/dist/contracts/index.d.ts.map +1 -1
  47. package/dist/derivatives/registration.d.ts +0 -5
  48. package/dist/derivatives/registration.d.ts.map +1 -1
  49. package/dist/derivatives/registration.js +2 -1
  50. package/dist/derivatives/registration.js.map +1 -1
  51. package/dist/errors/pdpp-catalog.d.ts +36 -0
  52. package/dist/errors/pdpp-catalog.d.ts.map +1 -0
  53. package/dist/errors/pdpp-catalog.js +86 -0
  54. package/dist/errors/pdpp-catalog.js.map +1 -0
  55. package/dist/grants/index.d.ts +2 -0
  56. package/dist/grants/index.d.ts.map +1 -1
  57. package/dist/grants/index.js +4 -0
  58. package/dist/grants/index.js.map +1 -1
  59. package/dist/grants/pdpp-binding-store.d.ts +89 -0
  60. package/dist/grants/pdpp-binding-store.d.ts.map +1 -0
  61. package/dist/grants/pdpp-binding-store.js +112 -0
  62. package/dist/grants/pdpp-binding-store.js.map +1 -0
  63. package/dist/grants/pdpp-binding.d.ts +169 -0
  64. package/dist/grants/pdpp-binding.d.ts.map +1 -0
  65. package/dist/grants/pdpp-binding.js +236 -0
  66. package/dist/grants/pdpp-binding.js.map +1 -0
  67. package/dist/legacy-projection/bindings.d.ts +28 -1
  68. package/dist/legacy-projection/bindings.d.ts.map +1 -1
  69. package/dist/legacy-projection/bindings.js +622 -189
  70. package/dist/legacy-projection/bindings.js.map +1 -1
  71. package/dist/legacy-projection/claude-source-record.d.ts +22 -0
  72. package/dist/legacy-projection/claude-source-record.d.ts.map +1 -0
  73. package/dist/legacy-projection/claude-source-record.js +360 -0
  74. package/dist/legacy-projection/claude-source-record.js.map +1 -0
  75. package/dist/legacy-projection/declared-text.d.ts +41 -0
  76. package/dist/legacy-projection/declared-text.d.ts.map +1 -0
  77. package/dist/legacy-projection/declared-text.js +199 -0
  78. package/dist/legacy-projection/declared-text.js.map +1 -0
  79. package/dist/legacy-projection/github-browser-bindings.d.ts.map +1 -1
  80. package/dist/legacy-projection/github-browser-bindings.js +89 -5
  81. package/dist/legacy-projection/github-browser-bindings.js.map +1 -1
  82. package/dist/legacy-projection/github-legacy-fields.d.ts +6 -0
  83. package/dist/legacy-projection/github-legacy-fields.d.ts.map +1 -0
  84. package/dist/legacy-projection/github-legacy-fields.js +202 -0
  85. package/dist/legacy-projection/github-legacy-fields.js.map +1 -0
  86. package/dist/legacy-projection/index.d.ts +3 -1
  87. package/dist/legacy-projection/index.d.ts.map +1 -1
  88. package/dist/legacy-projection/index.js +49 -1
  89. package/dist/legacy-projection/index.js.map +1 -1
  90. package/dist/legacy-projection/oura-browser-bindings.d.ts.map +1 -1
  91. package/dist/legacy-projection/oura-browser-bindings.js +43 -41
  92. package/dist/legacy-projection/oura-browser-bindings.js.map +1 -1
  93. package/dist/legacy-projection/types.d.ts +38 -2
  94. package/dist/legacy-projection/types.d.ts.map +1 -1
  95. package/dist/mcp/resources.d.ts.map +1 -1
  96. package/dist/mcp/resources.js +3 -0
  97. package/dist/mcp/resources.js.map +1 -1
  98. package/dist/mcp/tools.d.ts.map +1 -1
  99. package/dist/mcp/tools.js +11 -2
  100. package/dist/mcp/tools.js.map +1 -1
  101. package/dist/pdpp/approval.d.ts +271 -0
  102. package/dist/pdpp/approval.d.ts.map +1 -0
  103. package/dist/pdpp/approval.js +467 -0
  104. package/dist/pdpp/approval.js.map +1 -0
  105. package/dist/pdpp/client-identity.d.ts +88 -0
  106. package/dist/pdpp/client-identity.d.ts.map +1 -0
  107. package/dist/pdpp/client-identity.js +196 -0
  108. package/dist/pdpp/client-identity.js.map +1 -0
  109. package/dist/pdpp/client-metadata.d.ts +95 -0
  110. package/dist/pdpp/client-metadata.d.ts.map +1 -0
  111. package/dist/pdpp/client-metadata.js +96 -0
  112. package/dist/pdpp/client-metadata.js.map +1 -0
  113. package/dist/pdpp/commitments.d.ts +75 -0
  114. package/dist/pdpp/commitments.d.ts.map +1 -0
  115. package/dist/pdpp/commitments.js +113 -0
  116. package/dist/pdpp/commitments.js.map +1 -0
  117. package/dist/pdpp/declaration.d.ts +118 -0
  118. package/dist/pdpp/declaration.d.ts.map +1 -0
  119. package/dist/pdpp/declaration.js +836 -0
  120. package/dist/pdpp/declaration.js.map +1 -0
  121. package/dist/pdpp/index.d.ts +37 -0
  122. package/dist/pdpp/index.d.ts.map +1 -0
  123. package/dist/pdpp/index.js +23 -0
  124. package/dist/pdpp/index.js.map +1 -0
  125. package/dist/pdpp/issuance.d.ts +126 -0
  126. package/dist/pdpp/issuance.d.ts.map +1 -0
  127. package/dist/pdpp/issuance.js +180 -0
  128. package/dist/pdpp/issuance.js.map +1 -0
  129. package/dist/pdpp/pkce.d.ts +53 -0
  130. package/dist/pdpp/pkce.d.ts.map +1 -0
  131. package/dist/pdpp/pkce.js +109 -0
  132. package/dist/pdpp/pkce.js.map +1 -0
  133. package/dist/pdpp/redirect.d.ts +60 -0
  134. package/dist/pdpp/redirect.d.ts.map +1 -0
  135. package/dist/pdpp/redirect.js +125 -0
  136. package/dist/pdpp/redirect.js.map +1 -0
  137. package/dist/pdpp/resolve.d.ts +109 -0
  138. package/dist/pdpp/resolve.d.ts.map +1 -0
  139. package/dist/pdpp/resolve.js +393 -0
  140. package/dist/pdpp/resolve.js.map +1 -0
  141. package/dist/pdpp/review.d.ts +280 -0
  142. package/dist/pdpp/review.d.ts.map +1 -0
  143. package/dist/pdpp/review.js +205 -0
  144. package/dist/pdpp/review.js.map +1 -0
  145. package/dist/pdpp/selection.d.ts +50 -0
  146. package/dist/pdpp/selection.d.ts.map +1 -0
  147. package/dist/pdpp/selection.js +442 -0
  148. package/dist/pdpp/selection.js.map +1 -0
  149. package/dist/pdpp/store.d.ts +228 -0
  150. package/dist/pdpp/store.d.ts.map +1 -0
  151. package/dist/pdpp/store.js +542 -0
  152. package/dist/pdpp/store.js.map +1 -0
  153. package/dist/pdpp/tokens.d.ts +181 -0
  154. package/dist/pdpp/tokens.d.ts.map +1 -0
  155. package/dist/pdpp/tokens.js +419 -0
  156. package/dist/pdpp/tokens.js.map +1 -0
  157. package/dist/pdpp/types.d.ts +441 -0
  158. package/dist/pdpp/types.d.ts.map +1 -0
  159. package/dist/pdpp/types.js +61 -0
  160. package/dist/pdpp/types.js.map +1 -0
  161. package/dist/pdpp-version.d.ts +28 -0
  162. package/dist/pdpp-version.d.ts.map +1 -0
  163. package/dist/pdpp-version.js +28 -0
  164. package/dist/pdpp-version.js.map +1 -0
  165. package/dist/policy/data-read.d.ts +24 -0
  166. package/dist/policy/data-read.d.ts.map +1 -1
  167. package/dist/policy/data-read.js +60 -3
  168. package/dist/policy/data-read.js.map +1 -1
  169. package/dist/policy/data-write.d.ts.map +1 -1
  170. package/dist/policy/data-write.js +3 -0
  171. package/dist/policy/data-write.js.map +1 -1
  172. package/dist/policy/index.d.ts +1 -0
  173. package/dist/policy/index.d.ts.map +1 -1
  174. package/dist/policy/index.js +1 -0
  175. package/dist/policy/index.js.map +1 -1
  176. package/dist/policy/reserved-scopes.d.ts +3 -0
  177. package/dist/policy/reserved-scopes.d.ts.map +1 -0
  178. package/dist/policy/reserved-scopes.js +5 -0
  179. package/dist/policy/reserved-scopes.js.map +1 -0
  180. package/dist/ports/index.d.ts +63 -0
  181. package/dist/ports/index.d.ts.map +1 -1
  182. package/dist/ports/index.js.map +1 -1
  183. package/dist/ports/pdpp-auth.d.ts +129 -0
  184. package/dist/ports/pdpp-auth.d.ts.map +1 -0
  185. package/dist/ports/pdpp-auth.js +60 -0
  186. package/dist/ports/pdpp-auth.js.map +1 -0
  187. package/dist/ports/pdpp-auth.test-utils.d.ts +9 -0
  188. package/dist/ports/pdpp-auth.test-utils.d.ts.map +1 -0
  189. package/dist/ports/pdpp-auth.test-utils.js +18 -0
  190. package/dist/ports/pdpp-auth.test-utils.js.map +1 -0
  191. package/dist/schemas/server-config.d.ts +44 -0
  192. package/dist/schemas/server-config.d.ts.map +1 -1
  193. package/dist/schemas/server-config.js +91 -0
  194. package/dist/schemas/server-config.js.map +1 -1
  195. package/dist/signing/request-signer.d.ts +2 -0
  196. package/dist/signing/request-signer.d.ts.map +1 -1
  197. package/dist/signing/request-signer.js +1 -0
  198. package/dist/signing/request-signer.js.map +1 -1
  199. package/dist/storage/adapters/interface.d.ts +3 -0
  200. package/dist/storage/adapters/interface.d.ts.map +1 -1
  201. package/dist/storage/adapters/vana-audience.d.ts +12 -0
  202. package/dist/storage/adapters/vana-audience.d.ts.map +1 -0
  203. package/dist/storage/adapters/vana-audience.js +72 -0
  204. package/dist/storage/adapters/vana-audience.js.map +1 -0
  205. package/dist/storage/adapters/vana.d.ts +2 -0
  206. package/dist/storage/adapters/vana.d.ts.map +1 -1
  207. package/dist/storage/adapters/vana.js +53 -20
  208. package/dist/storage/adapters/vana.js.map +1 -1
  209. package/dist/storage/canonical-legacy-projection.d.ts +262 -0
  210. package/dist/storage/canonical-legacy-projection.d.ts.map +1 -0
  211. package/dist/storage/canonical-legacy-projection.js +761 -0
  212. package/dist/storage/canonical-legacy-projection.js.map +1 -0
  213. package/dist/storage/index/manager.d.ts +29 -2
  214. package/dist/storage/index/manager.d.ts.map +1 -1
  215. package/dist/storage/index/schema.d.ts +1 -1
  216. package/dist/storage/index/schema.js +1 -1
  217. package/dist/storage/index/types.d.ts +7 -1
  218. package/dist/storage/index/types.d.ts.map +1 -1
  219. package/dist/storage/legacy-projection.d.ts +4 -3
  220. package/dist/storage/legacy-projection.d.ts.map +1 -1
  221. package/dist/storage/legacy-projection.js +145 -42
  222. package/dist/storage/legacy-projection.js.map +1 -1
  223. package/dist/storage/legacy-scope-coverage-exceptions.d.ts +8 -0
  224. package/dist/storage/legacy-scope-coverage-exceptions.d.ts.map +1 -0
  225. package/dist/storage/legacy-scope-coverage-exceptions.js +10 -0
  226. package/dist/storage/legacy-scope-coverage-exceptions.js.map +1 -0
  227. package/dist/storage/legacy-scope-coverage.d.ts +53 -0
  228. package/dist/storage/legacy-scope-coverage.d.ts.map +1 -0
  229. package/dist/storage/legacy-scope-coverage.js +403 -0
  230. package/dist/storage/legacy-scope-coverage.js.map +1 -0
  231. package/dist/storage/legacy-scope-entity-keys.d.ts +4 -0
  232. package/dist/storage/legacy-scope-entity-keys.d.ts.map +1 -0
  233. package/dist/storage/legacy-scope-entity-keys.js +92 -0
  234. package/dist/storage/legacy-scope-entity-keys.js.map +1 -0
  235. package/dist/storage/legacy-scope-view.d.ts +154 -0
  236. package/dist/storage/legacy-scope-view.d.ts.map +1 -0
  237. package/dist/storage/legacy-scope-view.js +735 -0
  238. package/dist/storage/legacy-scope-view.js.map +1 -0
  239. package/dist/storage/pdpp-records/completion.d.ts +91 -0
  240. package/dist/storage/pdpp-records/completion.d.ts.map +1 -0
  241. package/dist/storage/pdpp-records/completion.js +170 -0
  242. package/dist/storage/pdpp-records/completion.js.map +1 -0
  243. package/dist/storage/pdpp-records/cursor.d.ts +41 -0
  244. package/dist/storage/pdpp-records/cursor.d.ts.map +1 -0
  245. package/dist/storage/pdpp-records/cursor.js +49 -0
  246. package/dist/storage/pdpp-records/cursor.js.map +1 -0
  247. package/dist/storage/pdpp-records/declaration-witness.d.ts +58 -0
  248. package/dist/storage/pdpp-records/declaration-witness.d.ts.map +1 -0
  249. package/dist/storage/pdpp-records/declaration-witness.js +230 -0
  250. package/dist/storage/pdpp-records/declaration-witness.js.map +1 -0
  251. package/dist/storage/pdpp-records/enforcement.d.ts +52 -0
  252. package/dist/storage/pdpp-records/enforcement.d.ts.map +1 -0
  253. package/dist/storage/pdpp-records/enforcement.js +116 -0
  254. package/dist/storage/pdpp-records/enforcement.js.map +1 -0
  255. package/dist/storage/pdpp-records/index.d.ts +5 -0
  256. package/dist/storage/pdpp-records/index.d.ts.map +1 -0
  257. package/dist/storage/pdpp-records/index.js +5 -0
  258. package/dist/storage/pdpp-records/index.js.map +1 -0
  259. package/dist/storage/pdpp-records/ingest-plan.d.ts +39 -0
  260. package/dist/storage/pdpp-records/ingest-plan.d.ts.map +1 -0
  261. package/dist/storage/pdpp-records/ingest-plan.js +103 -0
  262. package/dist/storage/pdpp-records/ingest-plan.js.map +1 -0
  263. package/dist/storage/pdpp-records/interface.d.ts +104 -0
  264. package/dist/storage/pdpp-records/interface.d.ts.map +1 -0
  265. package/dist/storage/pdpp-records/interface.js +2 -0
  266. package/dist/storage/pdpp-records/interface.js.map +1 -0
  267. package/dist/storage/pdpp-records/memory-store.d.ts +9 -0
  268. package/dist/storage/pdpp-records/memory-store.d.ts.map +1 -0
  269. package/dist/storage/pdpp-records/memory-store.js +466 -0
  270. package/dist/storage/pdpp-records/memory-store.js.map +1 -0
  271. package/dist/storage/pdpp-records/portable.d.ts +9 -0
  272. package/dist/storage/pdpp-records/portable.d.ts.map +1 -0
  273. package/dist/storage/pdpp-records/portable.js +8 -0
  274. package/dist/storage/pdpp-records/portable.js.map +1 -0
  275. package/dist/storage/pdpp-records/read-port.d.ts +44 -0
  276. package/dist/storage/pdpp-records/read-port.d.ts.map +1 -0
  277. package/dist/storage/pdpp-records/read-port.js +22 -0
  278. package/dist/storage/pdpp-records/read-port.js.map +1 -0
  279. package/dist/storage/pdpp-records/record-key.d.ts +52 -0
  280. package/dist/storage/pdpp-records/record-key.d.ts.map +1 -0
  281. package/dist/storage/pdpp-records/record-key.js +97 -0
  282. package/dist/storage/pdpp-records/record-key.js.map +1 -0
  283. package/dist/storage/pdpp-records/schema.d.ts +41 -0
  284. package/dist/storage/pdpp-records/schema.d.ts.map +1 -0
  285. package/dist/storage/pdpp-records/schema.js +153 -0
  286. package/dist/storage/pdpp-records/schema.js.map +1 -0
  287. package/dist/storage/pdpp-records/stream-declaration.d.ts +88 -0
  288. package/dist/storage/pdpp-records/stream-declaration.d.ts.map +1 -0
  289. package/dist/storage/pdpp-records/stream-declaration.js +57 -0
  290. package/dist/storage/pdpp-records/stream-declaration.js.map +1 -0
  291. package/dist/storage/pdpp-records/types.d.ts +138 -0
  292. package/dist/storage/pdpp-records/types.d.ts.map +1 -0
  293. package/dist/storage/pdpp-records/types.js +38 -0
  294. package/dist/storage/pdpp-records/types.js.map +1 -0
  295. package/dist/sync/data-point-feed.d.ts +3 -0
  296. package/dist/sync/data-point-feed.d.ts.map +1 -1
  297. package/dist/sync/data-point-feed.js +17 -1
  298. package/dist/sync/data-point-feed.js.map +1 -1
  299. package/dist/sync/delete-data-port.d.ts +2 -0
  300. package/dist/sync/delete-data-port.d.ts.map +1 -1
  301. package/dist/sync/delete-data-port.js +1 -1
  302. package/dist/sync/delete-data-port.js.map +1 -1
  303. package/dist/sync/engine/sync-manager.d.ts.map +1 -1
  304. package/dist/sync/engine/sync-manager.js +5 -2
  305. package/dist/sync/engine/sync-manager.js.map +1 -1
  306. package/dist/sync/index.d.ts +3 -1
  307. package/dist/sync/index.d.ts.map +1 -1
  308. package/dist/sync/index.js +4 -0
  309. package/dist/sync/index.js.map +1 -1
  310. package/dist/sync/pdpp-import.d.ts +192 -0
  311. package/dist/sync/pdpp-import.d.ts.map +1 -0
  312. package/dist/sync/pdpp-import.js +441 -0
  313. package/dist/sync/pdpp-import.js.map +1 -0
  314. package/dist/sync/scope-deletions.d.ts +10 -0
  315. package/dist/sync/scope-deletions.d.ts.map +1 -1
  316. package/dist/sync/scope-deletions.js +40 -7
  317. package/dist/sync/scope-deletions.js.map +1 -1
  318. package/dist/sync/workers/download.d.ts +14 -0
  319. package/dist/sync/workers/download.d.ts.map +1 -1
  320. package/dist/sync/workers/download.js +149 -13
  321. package/dist/sync/workers/download.js.map +1 -1
  322. package/dist/sync/workers/upload.d.ts +2 -0
  323. package/dist/sync/workers/upload.d.ts.map +1 -1
  324. package/dist/sync/workers/upload.js +7 -0
  325. package/dist/sync/workers/upload.js.map +1 -1
  326. package/dist/test-utils/memory-storage.d.ts +1 -1
  327. package/dist/test-utils/memory-storage.d.ts.map +1 -1
  328. package/dist/test-utils/memory-storage.js +11 -5
  329. package/dist/test-utils/memory-storage.js.map +1 -1
  330. package/package.json +54 -4
@@ -32,6 +32,58 @@ function ifDefined(record, sourceField, targetField) {
32
32
  ? {}
33
33
  : { [targetField]: record[sourceField] };
34
34
  }
35
+ export function ouraInteger(value) {
36
+ return typeof value === "number" && Number.isFinite(value)
37
+ ? Math.round(value)
38
+ : value;
39
+ }
40
+ function ifDefinedOuraInteger(record, sourceField, targetField) {
41
+ return record[sourceField] === undefined
42
+ ? {}
43
+ : { [targetField]: ouraInteger(record[sourceField]) };
44
+ }
45
+ export function ouraContributors(value, integerFields) {
46
+ if (!isObjectRow(value))
47
+ return undefined;
48
+ const contributors = { ...value };
49
+ for (const field of integerFields) {
50
+ if (Object.hasOwn(contributors, field)) {
51
+ contributors[field] = ouraInteger(contributors[field]);
52
+ }
53
+ }
54
+ return contributors;
55
+ }
56
+ export function withOuraContributors(value, integerFields) {
57
+ const contributors = ouraContributors(value, integerFields);
58
+ return contributors ? { contributors } : {};
59
+ }
60
+ const OURA_ACTIVITY_CONTRIBUTOR_INTEGERS = [
61
+ "meet_daily_targets",
62
+ "move_every_hour",
63
+ "recovery_time",
64
+ "stay_active",
65
+ "training_frequency",
66
+ "training_volume",
67
+ ];
68
+ const OURA_READINESS_CONTRIBUTOR_INTEGERS = [
69
+ "activity_balance",
70
+ "body_temperature",
71
+ "hrv_balance",
72
+ "previous_day_activity",
73
+ "previous_night",
74
+ "recovery_index",
75
+ "resting_heart_rate",
76
+ "sleep_balance",
77
+ ];
78
+ export const OURA_SLEEP_CONTRIBUTOR_INTEGERS = [
79
+ "deep_sleep",
80
+ "efficiency",
81
+ "latency",
82
+ "rem_sleep",
83
+ "restfulness",
84
+ "timing",
85
+ "total_sleep",
86
+ ];
35
87
  function missingStream(scope, expectedStream) {
36
88
  return {
37
89
  ok: false,
@@ -56,6 +108,85 @@ function missingStream(scope, expectedStream) {
56
108
  function projectionTime(options) {
57
109
  return options.now ?? new Date().toISOString();
58
110
  }
111
+ /**
112
+ * Counts source rows a binding leaves out and turns them into one
113
+ * `records_dropped` diagnostic per (stream, reason). Each row is counted
114
+ * once, under the first reason it failed, so counts sum to distinct rows.
115
+ */
116
+ class DroppedRows {
117
+ scope;
118
+ counts = new Map();
119
+ constructor(scope) {
120
+ this.scope = scope;
121
+ }
122
+ add(stream, reason, count = 1) {
123
+ if (count <= 0)
124
+ return;
125
+ const key = JSON.stringify([stream, reason]);
126
+ this.counts.set(key, (this.counts.get(key) ?? 0) + count);
127
+ }
128
+ diagnostics() {
129
+ return [...this.counts].map(([key, count]) => {
130
+ const [stream, reason] = JSON.parse(key);
131
+ return {
132
+ kind: "records_dropped",
133
+ scope: this.scope,
134
+ stream,
135
+ count,
136
+ reasons: [reason],
137
+ };
138
+ });
139
+ }
140
+ }
141
+ /** `{ diagnostics }` when there are any, else nothing, for spreading into a result. */
142
+ function withDiagnostics(diagnostics) {
143
+ return diagnostics.length > 0 ? { diagnostics } : {};
144
+ }
145
+ export const OURA_BAD_DAY = "Oura day is missing or not a YYYY-MM-DD date";
146
+ export const OURA_BAD_SCORE = "Oura score is absent or does not round to an integer from 0 to 100";
147
+ /** A real calendar date in the legacy `format: date` shape. */
148
+ export function isCalendarDay(value) {
149
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
150
+ return false;
151
+ }
152
+ const parsed = new Date(`${value}T00:00:00.000Z`);
153
+ return (!Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value));
154
+ }
155
+ /**
156
+ * The legacy Oura daily schemas require `score`, typed integer 0-100 or null.
157
+ * OpenPet decodes it as `*int`, so null is valid while fractional numbers are
158
+ * not. Rounding happens at the projection boundary.
159
+ */
160
+ function isOuraScore(value) {
161
+ if (value === null)
162
+ return true;
163
+ return (typeof value === "number" &&
164
+ Number.isFinite(value) &&
165
+ Math.round(value) >= 0 &&
166
+ Math.round(value) <= 100);
167
+ }
168
+ /**
169
+ * Oura rows the legacy schema cannot hold are left out and counted, never
170
+ * emitted with an impossible day or without the required score. A null score
171
+ * is valid under the frozen legacy schema.
172
+ * `scoreField` names the source field that becomes the legacy `score`
173
+ * (per row, or undefined for a row that produces no daily score). The
174
+ * caller decides what an all-dropped stream means.
175
+ */
176
+ export function partitionOuraRows(scope, stream, rows, scoreField) {
177
+ const dropped = new DroppedRows(scope);
178
+ const valid = rows.filter((row) => {
179
+ const field = scoreField(row);
180
+ if (!isCalendarDay(row.day))
181
+ dropped.add(stream, OURA_BAD_DAY);
182
+ else if (field !== undefined && !isOuraScore(row[field]))
183
+ dropped.add(stream, OURA_BAD_SCORE);
184
+ else
185
+ return true;
186
+ return false;
187
+ });
188
+ return { rows: valid, diagnostics: dropped.diagnostics() };
189
+ }
59
190
  function requireStreams(scope, options, expectedStreams) {
60
191
  for (const stream of expectedStreams) {
61
192
  if (!options.fetchedStreams.includes(stream)) {
@@ -189,6 +320,7 @@ const youtubeSubscriptions = {
189
320
  "handle",
190
321
  "avatar_url",
191
322
  "subscriber_count",
323
+ "subscriber_count_text",
192
324
  "description",
193
325
  "is_verified",
194
326
  "notifications",
@@ -197,6 +329,7 @@ const youtubeSubscriptions = {
197
329
  primaryKey: { subscriptions: ["id"] },
198
330
  lossy: [
199
331
  "Rows without legacy-required channelTitle/channelUrl or required notification booleans are skipped and reported.",
332
+ "subscriberCountText is null when the page rendered no count text; the connector omits subscriber_count_text then, and legacy's own default is null.",
200
333
  ],
201
334
  provenance: youtubeProvenance,
202
335
  project(records, options) {
@@ -232,7 +365,7 @@ const youtubeSubscriptions = {
232
365
  handle: youtubeStringOrNull(row.handle),
233
366
  channelId: youtubeStringOrNull(row.channel_id),
234
367
  avatarUrl: youtubeStringOrNull(row.avatar_url),
235
- subscriberCountText: null,
368
+ subscriberCountText: youtubeRequiredString(row.subscriber_count_text) ?? null,
236
369
  subscriberCount: typeof row.subscriber_count === "number"
237
370
  ? row.subscriber_count
238
371
  : null,
@@ -426,18 +559,20 @@ const youtubeHistory = {
426
559
  watch_history: [
427
560
  "position",
428
561
  "watched_date",
562
+ "watched_date_label",
429
563
  "video_id",
430
564
  "video_url",
431
565
  "video_title",
432
566
  "channel_title",
433
567
  "channel_url",
434
568
  "view_count",
569
+ "views_text",
435
570
  "description",
436
571
  ],
437
572
  },
438
573
  primaryKey: { watch_history: ["id"] },
439
574
  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.",
575
+ "Preserves page order by position and caps output at 50 entries. watchedAt carries watched_date (YYYY-MM-DD, null unchanged) at day precision only; no timestamp is inferred. watchedAtText carries watched_date_label (the browser section header as shown, as legacy emitted it); it falls back to watched_date only when the label is absent or empty. views_text is preserved when present; otherwise integer view_count is rendered as '<count> views'. Rows without required videoUrl are skipped and reported. Missing positions fail because page order cannot be preserved.",
441
576
  ],
442
577
  provenance: youtubeProvenance,
443
578
  project(records, options) {
@@ -464,14 +599,17 @@ const youtubeHistory = {
464
599
  return [];
465
600
  }
466
601
  const viewCount = typeof row.view_count === "number" ? row.view_count : null;
602
+ const viewsText = youtubeRequiredString(row.views_text);
467
603
  return [
468
604
  {
469
- watchedAtText: youtubeStringOrNull(row.watched_date),
605
+ watchedAt: youtubeStringOrNull(row.watched_date),
606
+ watchedAtText: youtubeRequiredString(row.watched_date_label) ??
607
+ youtubeStringOrNull(row.watched_date),
470
608
  videoId: youtubeStringOrNull(row.video_id),
471
609
  videoUrl,
472
610
  videoTitle: youtubeStringOrNull(row.video_title),
473
611
  channelTitle: youtubeStringOrNull(row.channel_title),
474
- views: viewCount === null ? null : `${viewCount} views`,
612
+ views: viewsText ?? (viewCount === null ? null : `${viewCount} views`),
475
613
  description: youtubeStringOrNull(row.description),
476
614
  _position: row.position,
477
615
  },
@@ -588,15 +726,14 @@ const instagramProfile = {
588
726
  "external_url",
589
727
  "follower_count",
590
728
  "following_count",
729
+ "post_count",
591
730
  "is_private",
592
731
  "is_verified",
593
732
  "is_business",
594
733
  ],
595
734
  },
596
735
  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
- ],
736
+ lossy: [],
600
737
  provenance: [
601
738
  {
602
739
  kind: "oci",
@@ -646,21 +783,21 @@ const instagramProfile = {
646
783
  const payload = {
647
784
  username,
648
785
  full_name: fullName,
786
+ bio: typeof p.bio === "string" ? p.bio : "",
649
787
  };
650
- if (typeof p.bio === "string")
651
- payload.bio = p.bio;
652
788
  if (typeof p.profile_pic_url === "string") {
653
789
  payload.profile_pic_url = p.profile_pic_url;
654
790
  }
655
- if (typeof p.external_url === "string") {
656
- payload.external_url = p.external_url;
657
- }
791
+ payload.external_url =
792
+ typeof p.external_url === "string" ? p.external_url : "";
658
793
  if (typeof p.follower_count === "number") {
659
794
  payload.follower_count = p.follower_count;
660
795
  }
661
796
  if (typeof p.following_count === "number") {
662
797
  payload.following_count = p.following_count;
663
798
  }
799
+ if (typeof p.post_count === "number")
800
+ payload.media_count = p.post_count;
664
801
  if (typeof p.is_private === "boolean")
665
802
  payload.is_private = p.is_private;
666
803
  if (typeof p.is_verified === "boolean")
@@ -693,6 +830,7 @@ const instagramPosts = {
693
830
  "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
831
  "who_liked.profile_pic_url is omitted because the post_likes stream does not declare it",
695
832
  "who_liked.pk and who_liked.id both use post_likes.user_id because the source exposes one liker identity field",
833
+ "likers without post_id, user_id, or username, or without a matching post, are dropped and counted",
696
834
  ],
697
835
  provenance: [
698
836
  {
@@ -711,11 +849,18 @@ const instagramPosts = {
711
849
  return guard;
712
850
  }
713
851
  const sourcePosts = byStream(records, "posts");
852
+ const postIds = new Set(sourcePosts.map((post) => post.id));
853
+ const dropped = new DroppedRows("instagram.posts");
714
854
  const likesByPost = new Map();
715
855
  for (const liker of byStream(records, "post_likes")) {
716
856
  if (typeof liker.post_id !== "string" ||
717
857
  typeof liker.user_id !== "string" ||
718
858
  typeof liker.username !== "string") {
859
+ dropped.add("post_likes", "Liker lacks post_id, user_id, or username");
860
+ continue;
861
+ }
862
+ if (!postIds.has(liker.post_id)) {
863
+ dropped.add("post_likes", "Liker has no matching post");
719
864
  continue;
720
865
  }
721
866
  const whoLiked = likesByPost.get(liker.post_id) ?? [];
@@ -744,6 +889,7 @@ const instagramPosts = {
744
889
  return {
745
890
  ok: true,
746
891
  payload: { posts },
892
+ ...withDiagnostics(dropped.diagnostics()),
747
893
  };
748
894
  },
749
895
  };
@@ -768,7 +914,7 @@ const instagramFollowing = {
768
914
  },
769
915
  primaryKey: { following: ["id"] },
770
916
  lossy: [
771
- "rows without a non-empty string username are skipped because the legacy account schema requires username",
917
+ "rows without a non-empty string username are dropped and counted because the legacy account schema requires username",
772
918
  ],
773
919
  provenance: [
774
920
  {
@@ -782,9 +928,12 @@ const instagramFollowing = {
782
928
  const guard = requireStreams("instagram.following", options, ["following"]);
783
929
  if (guard)
784
930
  return guard;
931
+ const dropped = new DroppedRows("instagram.following");
785
932
  const accounts = byStream(records, "following").flatMap((r) => {
786
- if (typeof r.username !== "string" || !r.username)
933
+ if (typeof r.username !== "string" || !r.username) {
934
+ dropped.add("following", "Account lacks a nonempty username");
787
935
  return [];
936
+ }
788
937
  const account = {
789
938
  username: r.username,
790
939
  full_name: typeof r.full_name === "string" ? r.full_name : "",
@@ -798,7 +947,11 @@ const instagramFollowing = {
798
947
  account.pk = r.id;
799
948
  return [account];
800
949
  });
801
- return { ok: true, payload: { accounts, total: accounts.length } };
950
+ return {
951
+ ok: true,
952
+ payload: { accounts, total: accounts.length },
953
+ ...withDiagnostics(dropped.diagnostics()),
954
+ };
802
955
  },
803
956
  };
804
957
  const instagramAds = {
@@ -810,7 +963,7 @@ const instagramAds = {
810
963
  primaryKey: { ads: ["id"] },
811
964
  lossy: [
812
965
  "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",
966
+ "malformed ads rows without a string name or one of the declaration's three kinds are dropped and counted",
814
967
  ],
815
968
  provenance: [
816
969
  {
@@ -827,9 +980,13 @@ const instagramAds = {
827
980
  const advertisers = [];
828
981
  const ad_topics = [];
829
982
  const categories = [];
983
+ const dropped = new DroppedRows("instagram.ads");
830
984
  for (const row of byStream(records, "ads")) {
831
- if (typeof row.name !== "string")
985
+ if (typeof row.name !== "string" ||
986
+ !["advertiser", "ad_topic", "ad_category"].includes(row.kind)) {
987
+ dropped.add("ads", "Ad row lacks a string name or a declared kind");
832
988
  continue;
989
+ }
833
990
  if (row.kind === "advertiser")
834
991
  advertisers.push({ name: row.name });
835
992
  if (row.kind === "ad_topic")
@@ -843,7 +1000,11 @@ const instagramAds = {
843
1000
  });
844
1001
  }
845
1002
  }
846
- return { ok: true, payload: { advertisers, ad_topics, categories } };
1003
+ return {
1004
+ ok: true,
1005
+ payload: { advertisers, ad_topics, categories },
1006
+ ...withDiagnostics(dropped.diagnostics()),
1007
+ };
847
1008
  },
848
1009
  };
849
1010
  // ---------------------------------------------------------------------------
@@ -1014,7 +1175,7 @@ const githubHistory = {
1014
1175
  "body is truncated to the legacy connector's 500-character limit",
1015
1176
  "repoUrl is omitted because no repository URL field is declared on issue or pull request streams",
1016
1177
  "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",
1178
+ "an authored item without an id or repository name, or an issue by the signed user without a boolean is_pull_request, fails the scope",
1018
1179
  ],
1019
1180
  provenance: [
1020
1181
  {
@@ -1085,6 +1246,16 @@ const githubHistory = {
1085
1246
  : [];
1086
1247
  return item;
1087
1248
  };
1249
+ if (sourceIssues.some((r) => r.user_login === login && typeof r.is_pull_request !== "boolean")) {
1250
+ return {
1251
+ ok: false,
1252
+ error: {
1253
+ kind: "incomplete_scope",
1254
+ scope: "github.history",
1255
+ reason: "An issue by the signed user has no boolean is_pull_request, so it cannot be classified",
1256
+ },
1257
+ };
1258
+ }
1088
1259
  const pullRequests = byStream(records, "pull_requests").flatMap((r) => {
1089
1260
  const item = projectItem(r, "pr");
1090
1261
  return item ? [item] : [];
@@ -1183,6 +1354,7 @@ const githubProfile = {
1183
1354
  error: { kind: "empty_singleton", scope: "github.profile" },
1184
1355
  };
1185
1356
  }
1357
+ const dropped = new DroppedRows("github.profile");
1186
1358
  const payload = {
1187
1359
  username: user.login,
1188
1360
  profileUrl: `https://github.com/${user.login}`,
@@ -1199,7 +1371,12 @@ const githubProfile = {
1199
1371
  payload[target] = user[source];
1200
1372
  }
1201
1373
  const stats = byStream(records, "user_stats")
1202
- .filter((r) => typeof r.observed_on === "string")
1374
+ .filter((r) => {
1375
+ if (typeof r.observed_on === "string")
1376
+ return true;
1377
+ dropped.add("user_stats", "Stats record lacks observed_on");
1378
+ return false;
1379
+ })
1203
1380
  .sort((a, b) => String(b.observed_on).localeCompare(String(a.observed_on)))[0];
1204
1381
  if (stats) {
1205
1382
  for (const [source, target] of [
@@ -1229,8 +1406,10 @@ const githubProfile = {
1229
1406
  });
1230
1407
  }
1231
1408
  payload.pinnedRepositories = byStream(records, "pinned_repositories").flatMap((r) => {
1232
- if (typeof r.full_name !== "string")
1409
+ if (typeof r.full_name !== "string") {
1410
+ dropped.add("pinned_repositories", "Pinned repository lacks full_name");
1233
1411
  return [];
1412
+ }
1234
1413
  const item = { fullName: r.full_name };
1235
1414
  if (typeof r.html_url === "string" || r.html_url === null)
1236
1415
  item.url = r.html_url;
@@ -1243,8 +1422,10 @@ const githubProfile = {
1243
1422
  return [item];
1244
1423
  });
1245
1424
  payload.organizations = byStream(records, "organizations").flatMap((r) => {
1246
- if (typeof r.login !== "string")
1425
+ if (typeof r.login !== "string") {
1426
+ dropped.add("organizations", "Organization lacks login");
1247
1427
  return [];
1428
+ }
1248
1429
  return [
1249
1430
  {
1250
1431
  login: r.login,
@@ -1254,7 +1435,7 @@ const githubProfile = {
1254
1435
  },
1255
1436
  ];
1256
1437
  });
1257
- return { ok: true, payload };
1438
+ return { ok: true, payload, ...withDiagnostics(dropped.diagnostics()) };
1258
1439
  },
1259
1440
  };
1260
1441
  const githubEvents = {
@@ -1270,7 +1451,7 @@ const githubEvents = {
1270
1451
  "the events API retains only roughly 90 days; the connector emits each collected public event as a legacy event",
1271
1452
  "legacy action, title, body, URL, branch, commit count, repository URL, and window description are omitted because events records do not declare those values",
1272
1453
  "fetchedAt is set at projection time because the stream has no collection timestamp",
1273
- "malformed records missing legacy-required fields are skipped",
1454
+ "malformed records missing legacy-required fields are dropped and counted",
1274
1455
  ],
1275
1456
  provenance: [
1276
1457
  {
@@ -1284,13 +1465,16 @@ const githubEvents = {
1284
1465
  const guard = requireStreams("github.events", options, ["events"]);
1285
1466
  if (guard)
1286
1467
  return guard;
1468
+ const dropped = new DroppedRows("github.events");
1287
1469
  const events = byStream(records, "events").flatMap((r) => {
1288
1470
  if (typeof r.id !== "string" ||
1289
1471
  typeof r.type !== "string" ||
1290
1472
  typeof r.created_at !== "string" ||
1291
1473
  typeof r.repository_full_name !== "string" ||
1292
- typeof r.is_public !== "boolean")
1474
+ typeof r.is_public !== "boolean") {
1475
+ dropped.add("events", "Event lacks a legacy-required id, type, created_at, repository_full_name, or is_public");
1293
1476
  return [];
1477
+ }
1294
1478
  return [
1295
1479
  {
1296
1480
  id: r.id,
@@ -1304,6 +1488,7 @@ const githubEvents = {
1304
1488
  return {
1305
1489
  ok: true,
1306
1490
  payload: { events, fetchedAt: projectionTime(options) },
1491
+ ...withDiagnostics(dropped.diagnostics()),
1307
1492
  };
1308
1493
  },
1309
1494
  };
@@ -1318,6 +1503,7 @@ const githubContributions = {
1318
1503
  "daily records are projected to legacy days; level is absent from the source and omitted",
1319
1504
  "totalContributionsLastYear, yearTotals, monthlyTotals, and topDay are omitted because collected contribution records may cover only an incremental or caller-limited interval",
1320
1505
  "fetchedAt is set at projection time because contribution records have no collection timestamp",
1506
+ "days without a date or numeric count are dropped and counted",
1321
1507
  ],
1322
1508
  provenance: [
1323
1509
  {
@@ -1333,13 +1519,20 @@ const githubContributions = {
1333
1519
  ]);
1334
1520
  if (guard)
1335
1521
  return guard;
1522
+ const dropped = new DroppedRows("github.contributions");
1336
1523
  const days = byStream(records, "contributions").flatMap((r) => {
1337
1524
  if (typeof r.date !== "string" ||
1338
- typeof r.contribution_count !== "number")
1525
+ typeof r.contribution_count !== "number") {
1526
+ dropped.add("contributions", "Contribution day lacks a date or a numeric contribution_count");
1339
1527
  return [];
1528
+ }
1340
1529
  return [{ date: r.date, count: r.contribution_count }];
1341
1530
  });
1342
- return { ok: true, payload: { days, fetchedAt: projectionTime(options) } };
1531
+ return {
1532
+ ok: true,
1533
+ payload: { days, fetchedAt: projectionTime(options) },
1534
+ ...withDiagnostics(dropped.diagnostics()),
1535
+ };
1343
1536
  },
1344
1537
  };
1345
1538
  // ---------------------------------------------------------------------------
@@ -1431,6 +1624,7 @@ const chatgptConversations = {
1431
1624
  "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
1625
  "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
1626
  "A missing title becomes 'Untitled', matching the legacy connector's toConversationRecord fallback; model_slug is renamed to model.",
1627
+ "Legacy message ids are source node ids. ChatGPT 0.3.0 stores a message under '<conversation_id>:<node_id>' (and links parent_id the same way); the projection strips that prefix, reads a bare pre-0.3.0 id as it is, and counts a node stored under both forms once, preferring the 0.3.0 row.",
1434
1628
  ],
1435
1629
  provenance: [
1436
1630
  {
@@ -1456,14 +1650,17 @@ const chatgptConversations = {
1456
1650
  const dropped = new Map();
1457
1651
  const drop = (reason) => dropped.set(reason, (dropped.get(reason) ?? 0) + 1);
1458
1652
  let malformedMessages = 0;
1653
+ let unlinkedMessages = 0;
1459
1654
  const messagesByConversation = new Map();
1460
1655
  for (const message of byStream(records, "messages")) {
1461
1656
  if (!isObjectRow(message)) {
1462
1657
  malformedMessages += 1;
1463
1658
  continue;
1464
1659
  }
1465
- if (typeof message.conversation_id !== "string")
1660
+ if (typeof message.conversation_id !== "string") {
1661
+ unlinkedMessages += 1;
1466
1662
  continue;
1663
+ }
1467
1664
  const group = messagesByConversation.get(message.conversation_id) ?? [];
1468
1665
  group.push(message);
1469
1666
  messagesByConversation.set(message.conversation_id, group);
@@ -1513,20 +1710,29 @@ const chatgptConversations = {
1513
1710
  drop("Conversation lacks a current-branch message count");
1514
1711
  continue;
1515
1712
  }
1516
- const branch = (messagesByConversation.get(id) ?? []).filter((m) => m.on_current_branch === true);
1713
+ // Legacy names a message by its source node id. Messages are stored
1714
+ // under their record id: the bare node id before ChatGPT 0.3.0,
1715
+ // `<conversation_id>:<node_id>` since. An instance that holds both
1716
+ // forms of one node uses the current one.
1717
+ const { byNode, unkeyed } = chatgptMessagesByNode(id, messagesByConversation.get(id));
1718
+ const branch = [...byNode.values(), ...unkeyed].filter((m) => m.on_current_branch === true);
1517
1719
  if (branch.length !== message_count_on_current_branch) {
1518
1720
  drop("Current branch message count does not match conversation");
1519
1721
  continue;
1520
1722
  }
1521
- const byId = new Map(branch.filter((m) => typeof m.id === "string").map((m) => [m.id, m]));
1522
1723
  const ordered = [];
1523
1724
  const seen = new Set();
1524
1725
  let node = typeof current_node === "string" ? current_node : null;
1525
- while (node && byId.has(node) && !seen.has(node)) {
1726
+ while (node && !seen.has(node)) {
1727
+ const message = byNode.get(node);
1728
+ if (!message || message.on_current_branch !== true)
1729
+ break;
1526
1730
  seen.add(node);
1527
- const message = byId.get(node);
1528
1731
  ordered.push(message);
1529
- node = typeof message.parent_id === "string" ? message.parent_id : null;
1732
+ node =
1733
+ typeof message.parent_id === "string"
1734
+ ? chatgptNodeId(id, message.parent_id)
1735
+ : null;
1530
1736
  }
1531
1737
  if (ordered.length !== branch.length) {
1532
1738
  drop("Current branch message chain is incomplete");
@@ -1540,7 +1746,7 @@ const chatgptConversations = {
1540
1746
  typeof m.content === "string" &&
1541
1747
  m.content.length > 0)
1542
1748
  .map((m) => ({
1543
- id: m.id,
1749
+ id: chatgptNodeId(id, m.id),
1544
1750
  role: m.role,
1545
1751
  content: m.content,
1546
1752
  content_type: m.content_type,
@@ -1567,17 +1773,18 @@ const chatgptConversations = {
1567
1773
  reasons: [...dropped.keys()],
1568
1774
  });
1569
1775
  }
1570
- if (orphanMessages + malformedMessages > 0) {
1776
+ if (orphanMessages + malformedMessages + unlinkedMessages > 0) {
1571
1777
  diagnostics.push({
1572
1778
  kind: "records_dropped",
1573
1779
  scope,
1574
1780
  stream: "messages",
1575
- count: orphanMessages + malformedMessages,
1781
+ count: orphanMessages + malformedMessages + unlinkedMessages,
1576
1782
  reasons: [
1577
1783
  ...(orphanMessages > 0
1578
1784
  ? ["Messages have no matching conversation"]
1579
1785
  : []),
1580
1786
  ...(malformedMessages > 0 ? ["Message record is not an object"] : []),
1787
+ ...(unlinkedMessages > 0 ? ["Message lacks a conversation id"] : []),
1581
1788
  ],
1582
1789
  });
1583
1790
  }
@@ -1595,6 +1802,41 @@ const chatgptConversations = {
1595
1802
  };
1596
1803
  },
1597
1804
  };
1805
+ /**
1806
+ * The source node id of a ChatGPT message record id. ChatGPT 0.3.0 and later
1807
+ * name a message `<conversation_id>:<node_id>`, because a node id can recur in
1808
+ * a forked conversation; earlier versions stored the bare node id. A
1809
+ * conversation id is a UUID and never contains ":", so the prefix is
1810
+ * unambiguous. Applies to `parent_id` too, which names a record the same way.
1811
+ */
1812
+ function chatgptNodeId(conversationId, recordId) {
1813
+ const prefix = `${conversationId}:`;
1814
+ return recordId.startsWith(prefix) ? recordId.slice(prefix.length) : recordId;
1815
+ }
1816
+ /**
1817
+ * One conversation's stored messages by source node id. When an instance holds
1818
+ * a node under both the pre-0.3.0 key and the 0.3.0 key, the 0.3.0 row wins:
1819
+ * it was written later, from the full source body.
1820
+ */
1821
+ function chatgptMessagesByNode(conversationId, messages) {
1822
+ const byNode = new Map();
1823
+ const unkeyed = [];
1824
+ const current = new Set();
1825
+ for (const message of messages ?? []) {
1826
+ if (typeof message.id !== "string") {
1827
+ unkeyed.push(message);
1828
+ continue;
1829
+ }
1830
+ const node = chatgptNodeId(conversationId, message.id);
1831
+ const isCurrent = node !== message.id;
1832
+ if (byNode.has(node) && (current.has(node) || !isCurrent))
1833
+ continue;
1834
+ byNode.set(node, message);
1835
+ if (isCurrent)
1836
+ current.add(node);
1837
+ }
1838
+ return { byNode, unkeyed };
1839
+ }
1598
1840
  /**
1599
1841
  * Newest `update_time` first; a missing or unparseable time sorts last.
1600
1842
  * Ties fall back to the id, so the order never depends on input order.
@@ -1611,7 +1853,9 @@ function newestUpdateFirst(a, b) {
1611
1853
  }
1612
1854
  // This is the merged 0.1.3 source declaration. Admission remains a separate
1613
1855
  // decision; this local manifest hash is not the signed OCI artifact digest.
1614
- const anthropicProjectionSource = "https://registry.pdpp.dev/connectors/anthropic";
1856
+ export const ANTHROPIC_CONNECTOR_ID = "https://registry.pdpp.dev/connectors/anthropic";
1857
+ // Connector/method identity differs from the public source in the admitted declaration.
1858
+ export const CLAUDE_SOURCE_ID = "https://registry.pdpp.dev/sources/claude";
1615
1859
  const anthropicProjectionProvenance = [
1616
1860
  {
1617
1861
  kind: "collection-profile",
@@ -1664,7 +1908,7 @@ function claudeConversationsBinding() {
1664
1908
  const scope = "claude.conversations";
1665
1909
  return {
1666
1910
  scope,
1667
- pdppSource: anthropicProjectionSource,
1911
+ pdppSource: ANTHROPIC_CONNECTOR_ID,
1668
1912
  pdppStreams: ["account_profile", "conversations", "messages"],
1669
1913
  legacySchemaPath: "claude.conversations.json",
1670
1914
  fieldsRead: {
@@ -1824,7 +2068,7 @@ function claudeConversationsBinding() {
1824
2068
  const claudeConversations = claudeConversationsBinding();
1825
2069
  const claudeProjects = {
1826
2070
  scope: "claude.projects",
1827
- pdppSource: anthropicProjectionSource,
2071
+ pdppSource: ANTHROPIC_CONNECTOR_ID,
1828
2072
  pdppStreams: ["account_profile", "projects", "project_documents"],
1829
2073
  legacySchemaPath: "claude.projects.json",
1830
2074
  fieldsRead: {
@@ -2403,7 +2647,7 @@ const spotifySavedTracks = {
2403
2647
  },
2404
2648
  primaryKey: { saved_tracks: ["id"] },
2405
2649
  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.",
2650
+ "Rows without the required legacy name and artist list are dropped and counted; nullable album and optional fields retain the connector's empty-string, zero, and false sentinels.",
2407
2651
  "isrc, popularity, and id have no corresponding legacy fields and are omitted.",
2408
2652
  ],
2409
2653
  provenance: [
@@ -2420,16 +2664,27 @@ const spotifySavedTracks = {
2420
2664
  ]);
2421
2665
  if (guard)
2422
2666
  return guard;
2667
+ const dropped = new Set();
2668
+ let droppedCount = 0;
2423
2669
  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"))
2670
+ const { name, artist_names: artistNames } = r;
2671
+ const artists = Array.isArray(artistNames) &&
2672
+ artistNames.every((entry) => typeof entry === "string")
2673
+ ? artistNames
2674
+ : null;
2675
+ if (typeof name !== "string" || !artists) {
2676
+ droppedCount += 1;
2677
+ if (typeof name !== "string")
2678
+ dropped.add("Saved track lacks a name");
2679
+ if (!artists)
2680
+ dropped.add("Saved track lacks a valid artist list");
2427
2681
  return [];
2682
+ }
2428
2683
  return [
2429
2684
  {
2430
2685
  added_at: orEmptyString(r.added_at),
2431
- name: r.name,
2432
- artists: r.artist_names.map((name) => ({ name })),
2686
+ name,
2687
+ artists: artists.map((artist) => ({ name: artist })),
2433
2688
  album: {
2434
2689
  name: orEmptyString(r.album_name),
2435
2690
  artists: Array.isArray(r.album_artist_names)
@@ -2444,7 +2699,23 @@ const spotifySavedTracks = {
2444
2699
  },
2445
2700
  ];
2446
2701
  });
2447
- return { ok: true, payload: { savedTracks: tracks, total: tracks.length } };
2702
+ return {
2703
+ ok: true,
2704
+ payload: { savedTracks: tracks, total: tracks.length },
2705
+ ...(droppedCount > 0
2706
+ ? {
2707
+ diagnostics: [
2708
+ {
2709
+ kind: "records_dropped",
2710
+ scope: "spotify.savedTracks",
2711
+ stream: "saved_tracks",
2712
+ count: droppedCount,
2713
+ reasons: [...dropped],
2714
+ },
2715
+ ],
2716
+ }
2717
+ : {}),
2718
+ };
2448
2719
  },
2449
2720
  };
2450
2721
  const spotifyPlaylists = {
@@ -2503,8 +2774,16 @@ const spotifyPlaylists = {
2503
2774
  const itemsByPlaylist = new Map();
2504
2775
  for (const item of byStream(records, "playlist_items")) {
2505
2776
  const playlistId = item.playlist_id;
2506
- if (typeof playlistId !== "string")
2507
- continue;
2777
+ if (typeof playlistId !== "string") {
2778
+ return {
2779
+ ok: false,
2780
+ error: {
2781
+ kind: "incomplete_scope",
2782
+ scope: "spotify.playlists",
2783
+ reason: "A playlist item has no playlist_id to join on",
2784
+ },
2785
+ };
2786
+ }
2508
2787
  const group = itemsByPlaylist.get(playlistId);
2509
2788
  if (group) {
2510
2789
  group.push(item);
@@ -2695,6 +2974,7 @@ const ouraActivity = {
2695
2974
  lossy: [
2696
2975
  "timestamp: no source field; omitted (legacy schema makes it optional)",
2697
2976
  "target_calories: has no legacy field and is not projected",
2977
+ "Legacy integer fields: fractional source numbers are rounded with Math.round at projection time",
2698
2978
  ],
2699
2979
  provenance: [
2700
2980
  {
@@ -2708,26 +2988,26 @@ const ouraActivity = {
2708
2988
  const guard = requireStreams("oura.activity", options, ["activity"]);
2709
2989
  if (guard)
2710
2990
  return guard;
2991
+ const { rows, diagnostics } = partitionOuraRows("oura.activity", "activity", byStream(records, "activity"), () => "score");
2711
2992
  return {
2712
2993
  ok: true,
2994
+ ...(diagnostics.length > 0 ? { diagnostics } : {}),
2713
2995
  payload: {
2714
- days: byStream(records, "activity").map((r) => ({
2996
+ days: rows.map((r) => ({
2715
2997
  id: r.id,
2716
2998
  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
- : {}),
2999
+ score: ouraInteger(r.score),
3000
+ ...ifDefinedOuraInteger(r, "active_calories", "activeCalories"),
3001
+ ...ifDefinedOuraInteger(r, "total_calories", "totalCalories"),
3002
+ ...ifDefinedOuraInteger(r, "steps", "steps"),
3003
+ ...ifDefinedOuraInteger(r, "equivalent_walking_distance", "equivalentWalkingDistance"),
3004
+ ...ifDefinedOuraInteger(r, "high_activity_time", "highActivityTime"),
3005
+ ...ifDefinedOuraInteger(r, "medium_activity_time", "mediumActivityTime"),
3006
+ ...ifDefinedOuraInteger(r, "low_activity_time", "lowActivityTime"),
3007
+ ...ifDefinedOuraInteger(r, "sedentary_time", "sedentaryTime"),
3008
+ ...ifDefinedOuraInteger(r, "resting_time", "restingTime"),
3009
+ ...ifDefinedOuraInteger(r, "inactivity_alerts", "inactivityAlerts"),
3010
+ ...withOuraContributors(r.contributors, OURA_ACTIVITY_CONTRIBUTOR_INTEGERS),
2731
3011
  })),
2732
3012
  },
2733
3013
  };
@@ -2751,6 +3031,7 @@ const ouraReadiness = {
2751
3031
  primaryKey: { readiness: ["id"] },
2752
3032
  lossy: [
2753
3033
  "timestamp: no source field; omitted (legacy schema makes it optional)",
3034
+ "Legacy integer fields: fractional source numbers are rounded with Math.round at projection time",
2754
3035
  ],
2755
3036
  provenance: [
2756
3037
  {
@@ -2764,18 +3045,18 @@ const ouraReadiness = {
2764
3045
  const guard = requireStreams("oura.readiness", options, ["readiness"]);
2765
3046
  if (guard)
2766
3047
  return guard;
3048
+ const { rows, diagnostics } = partitionOuraRows("oura.readiness", "readiness", byStream(records, "readiness"), () => "score");
2767
3049
  return {
2768
3050
  ok: true,
3051
+ ...(diagnostics.length > 0 ? { diagnostics } : {}),
2769
3052
  payload: {
2770
- days: byStream(records, "readiness").map((r) => ({
3053
+ days: rows.map((r) => ({
2771
3054
  id: r.id,
2772
3055
  day: r.day,
2773
- ...ifDefined(r, "score", "score"),
3056
+ score: ouraInteger(r.score),
2774
3057
  ...ifDefined(r, "temperature_deviation", "temperatureDeviation"),
2775
3058
  ...ifDefined(r, "temperature_trend_deviation", "temperatureTrendDeviation"),
2776
- ...(r.contributors && typeof r.contributors === "object"
2777
- ? { contributors: r.contributors }
2778
- : {}),
3059
+ ...withOuraContributors(r.contributors, OURA_READINESS_CONTRIBUTOR_INTEGERS),
2779
3060
  })),
2780
3061
  },
2781
3062
  };
@@ -2813,8 +3094,9 @@ const ouraSleep = {
2813
3094
  lossy: [
2814
3095
  "awakeTime: no declared PDPP field and is omitted",
2815
3096
  "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",
3097
+ "sleepPeriods.averageHrv: fractional PDPP numbers are rounded with Math.round to satisfy the legacy integer type",
2817
3098
  "sleepPeriods.type: unrecognized or null values are omitted because the legacy enum accepts only long_sleep, short_sleep or rest",
3099
+ "Legacy integer fields: fractional source numbers are rounded with Math.round at projection time",
2818
3100
  ],
2819
3101
  provenance: [
2820
3102
  {
@@ -2828,50 +3110,85 @@ const ouraSleep = {
2828
3110
  const guard = requireStreams("oura.sleep", options, ["sleep"]);
2829
3111
  if (guard)
2830
3112
  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
- }));
3113
+ const sourceRows = byStream(records, "sleep");
3114
+ const { rows, diagnostics } = partitionOuraRows("oura.sleep", "sleep", sourceRows, () => "sleep_score");
3115
+ const { rows: periodRows } = partitionOuraRows("oura.sleep", "sleep", sourceRows, () => undefined);
3116
+ const projectionOmissions = periodRows.flatMap(ouraSleepPeriodOmissions);
2859
3117
  return {
2860
3118
  ok: true,
3119
+ ...withDiagnostics(diagnostics),
3120
+ ...(projectionOmissions.length > 0 ? { projectionOmissions } : {}),
2861
3121
  payload: {
2862
3122
  dailyScores: rows.map((r) => ({
2863
3123
  id: r.id,
2864
3124
  day: r.day,
2865
- ...ifDefined(r, "sleep_score", "score"),
2866
- ...(r.contributors && typeof r.contributors === "object"
2867
- ? { contributors: r.contributors }
2868
- : {}),
3125
+ score: ouraInteger(r.sleep_score),
3126
+ ...withOuraContributors(r.contributors, OURA_SLEEP_CONTRIBUTOR_INTEGERS),
2869
3127
  })),
2870
- sleepPeriods: periods,
3128
+ sleepPeriods: periodRows.map(ouraSleepPeriod),
2871
3129
  },
2872
3130
  };
2873
3131
  },
2874
3132
  };
3133
+ /** One legacy `sleepPeriods` entry from a source sleep session row with a valid day. */
3134
+ export function ouraSleepPeriod(r) {
3135
+ return {
3136
+ id: r.id,
3137
+ day: r.day,
3138
+ ...(r.type === "long_sleep" || r.type === "short_sleep" || r.type === "rest"
3139
+ ? { type: r.type }
3140
+ : {}),
3141
+ ...(r.bedtime_start === null
3142
+ ? {}
3143
+ : ifDefined(r, "bedtime_start", "bedtimeStart")),
3144
+ ...(r.bedtime_end === null
3145
+ ? {}
3146
+ : ifDefined(r, "bedtime_end", "bedtimeEnd")),
3147
+ ...ifDefinedOuraInteger(r, "total_sleep_duration", "totalSleepDuration"),
3148
+ ...ifDefinedOuraInteger(r, "time_in_bed", "timeInBed"),
3149
+ ...ifDefinedOuraInteger(r, "deep_sleep_duration", "deepSleepDuration"),
3150
+ ...ifDefinedOuraInteger(r, "light_sleep_duration", "lightSleepDuration"),
3151
+ ...ifDefinedOuraInteger(r, "rem_sleep_duration", "remSleepDuration"),
3152
+ ...ifDefinedOuraInteger(r, "efficiency", "efficiency"),
3153
+ ...ifDefinedOuraInteger(r, "latency", "latency"),
3154
+ ...ifDefined(r, "average_heart_rate", "averageHeartRate"),
3155
+ ...ifDefinedOuraInteger(r, "average_hrv", "averageHrv"),
3156
+ ...ifDefinedOuraInteger(r, "lowest_heart_rate", "lowestHeartRate"),
3157
+ ...ifDefined(r, "average_breath", "averageBreath"),
3158
+ ...ifDefinedOuraInteger(r, "restless_periods", "restlessPeriods"),
3159
+ };
3160
+ }
3161
+ export function ouraSleepPeriodOmissions(r) {
3162
+ const entityKey = r.id;
3163
+ if ((typeof entityKey !== "string" &&
3164
+ typeof entityKey !== "number" &&
3165
+ typeof entityKey !== "boolean") ||
3166
+ entityKey === "") {
3167
+ return [];
3168
+ }
3169
+ const omissions = [];
3170
+ if (r.type === null ||
3171
+ (r.type !== undefined &&
3172
+ !["long_sleep", "short_sleep", "rest"].includes(String(r.type)))) {
3173
+ omissions.push({
3174
+ entityPath: "$.sleepPeriods[]",
3175
+ entityKey,
3176
+ fieldPath: "type",
3177
+ reason: r.type === null ? "null_type" : "off_enum_type",
3178
+ });
3179
+ }
3180
+ for (const field of ["bedtime_start", "bedtime_end"]) {
3181
+ if (r[field] === null) {
3182
+ omissions.push({
3183
+ entityPath: "$.sleepPeriods[]",
3184
+ entityKey,
3185
+ fieldPath: field === "bedtime_start" ? "bedtimeStart" : "bedtimeEnd",
3186
+ reason: "null_timestamp",
3187
+ });
3188
+ }
3189
+ }
3190
+ return omissions;
3191
+ }
2875
3192
  // ---------------------------------------------------------------------------
2876
3193
  // amazon
2877
3194
  // declarations/amazon.collection-profile.json, streams "orders" +
@@ -2910,6 +3227,10 @@ const amazonOrders = {
2910
3227
  }
2911
3228
  const orders = byStream(records, "orders");
2912
3229
  const items = byStream(records, "order_items");
3230
+ const orderIds = new Set(orders.map((o) => o.id));
3231
+ const orphanItems = items.filter((i) => !orderIds.has(i.order_id)).length;
3232
+ const dropped = new DroppedRows("amazon.orders");
3233
+ dropped.add("order_items", "Order item has no matching order", orphanItems);
2913
3234
  // orderDate: legacy scrapes "MonthName D, YYYY" out of card text via
2914
3235
  // regex (amazon-playwright.js:184-187); toLegacyLongDate reproduces
2915
3236
  // that exact text shape from PDPP's ISO order_date, not an ISO
@@ -2936,6 +3257,7 @@ const amazonOrders = {
2936
3257
  })),
2937
3258
  total: orders.length,
2938
3259
  },
3260
+ ...withDiagnostics(dropped.diagnostics()),
2939
3261
  };
2940
3262
  },
2941
3263
  };
@@ -3227,8 +3549,31 @@ const hebNutrition = {
3227
3549
  };
3228
3550
  },
3229
3551
  };
3230
- // Retained legacy H-E-B order projection, unchanged.
3552
+ // Grocery order semantics follow the accepted Unity projection fold
3553
+ // (vana-unity 9256b5a8e): an order with no usable line is a detail gap unless
3554
+ // the source count is a known zero; a gap or an unkeyable order is left out and
3555
+ // counted; zero survivors from nonempty orders is rejected, never served empty.
3556
+ // Reason strings are that fold's diagnostic codes.
3231
3557
  // ---------------------------------------------------------------------------
3558
+ /** Reason for an order dropped because its id is unusable; index.ts lets exactly these rows past its primary-key check. */
3559
+ export const GROCERY_INVALID_ORDER = "projection_invalid_order";
3560
+ const GROCERY_ORDER_ITEMS_GAP = "projection_order_items_gap";
3561
+ function partialRows(scope, stream, reason, count) {
3562
+ return count > 0
3563
+ ? [{ kind: "records_partial", scope, stream, count, reasons: [reason] }]
3564
+ : [];
3565
+ }
3566
+ function groceryRejection(scope, diagnostics) {
3567
+ return {
3568
+ ok: false,
3569
+ error: {
3570
+ kind: "incomplete_scope",
3571
+ scope,
3572
+ reason: "No source order could be projected",
3573
+ diagnostics,
3574
+ },
3575
+ };
3576
+ }
3232
3577
  const hebOrders = {
3233
3578
  scope: "heb.orders",
3234
3579
  pdppSource: "https://registry.pdpp.dev/connectors/heb",
@@ -3251,7 +3596,9 @@ const hebOrders = {
3251
3596
  "orderUrl and address: the signed profile has no equivalent fields; fulfillment_location is not treated as an address.",
3252
3597
  "status_code, store_name, timeslot_start/end, unfulfilled_count, fulfillment_method, and fetched_at are not represented by the legacy schema.",
3253
3598
  "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.",
3599
+ "the retained collector emitted only product-linked named lines; a source line without either field is dropped and counted, and its order is delivered without it.",
3600
+ "An order without a usable id, or with no usable line and an item_count that is not a known zero, is left out and counted; valid peers are still delivered.",
3601
+ "item_count counts quantity units; an order whose summed line quantities differ is delivered and reported partial.",
3255
3602
  "quantity: a declared numeric quantity is converted to its decimal string form to match the legacy connector output type.",
3256
3603
  ],
3257
3604
  provenance: [
@@ -3268,11 +3615,8 @@ const hebOrders = {
3268
3615
  if (guard)
3269
3616
  return guard;
3270
3617
  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();
3618
+ const sourceOrders = byStream(records, "orders");
3619
+ const sourceOrderIds = new Set(sourceOrders.map((order) => order.id));
3276
3620
  if (sourceItems.some((item) => !sourceOrderIds.has(item.order_id))) {
3277
3621
  return {
3278
3622
  ok: false,
@@ -3283,23 +3627,24 @@ const hebOrders = {
3283
3627
  },
3284
3628
  };
3285
3629
  }
3286
- const orders = byStream(records, "orders").flatMap((order) => {
3630
+ const dropped = new DroppedRows("heb.orders");
3631
+ let unitCountMismatches = 0;
3632
+ const orders = sourceOrders.flatMap((order) => {
3633
+ // Without a usable id an order cannot be keyed: it is left out and
3634
+ // counted (see GROCERY_INVALID_ORDER), never failing its valid peers.
3287
3635
  if (!isNonEmptyString(order.id)) {
3288
- skippedOrders += 1;
3289
- missingFields.add("orders.id");
3636
+ dropped.add("orders", GROCERY_INVALID_ORDER);
3290
3637
  return [];
3291
3638
  }
3292
3639
  const items = sourceItems
3293
3640
  .filter((item) => item.order_id === order.id)
3294
3641
  .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
- }
3642
+ if (!isNonEmptyString(item.name)) {
3643
+ dropped.add("order_items", "projection_order_item_missing_name");
3644
+ return [];
3645
+ }
3646
+ if (!isNonEmptyString(item.product_id)) {
3647
+ dropped.add("order_items", "projection_order_item_missing_product_id");
3303
3648
  return [];
3304
3649
  }
3305
3650
  const quantity = typeof item.quantity === "number" && Number.isFinite(item.quantity)
@@ -3319,10 +3664,19 @@ const hebOrders = {
3319
3664
  },
3320
3665
  ];
3321
3666
  });
3667
+ // No usable line proves an empty order only when the source count is a
3668
+ // known zero. A positive, missing, or malformed count is a detail gap:
3669
+ // the order is left out until its lines arrive.
3670
+ if (items.length === 0 && order.item_count !== 0) {
3671
+ dropped.add("orders", GROCERY_ORDER_ITEMS_GAP);
3672
+ return [];
3673
+ }
3674
+ // H-E-B item_count counts quantity units, not lines.
3322
3675
  if (typeof order.item_count === "number" &&
3323
- Number.isInteger(order.item_count) &&
3324
- order.item_count > items.length) {
3325
- orderItemsShortOfDeclaredCount = true;
3676
+ Number.isInteger(order.item_count)) {
3677
+ const units = items.reduce((total, item) => total + (item.quantity === null ? 0 : Number(item.quantity)), 0);
3678
+ if (units !== order.item_count)
3679
+ unitCountMismatches += 1;
3326
3680
  }
3327
3681
  return [
3328
3682
  {
@@ -3341,35 +3695,13 @@ const hebOrders = {
3341
3695
  },
3342
3696
  ];
3343
3697
  });
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
- };
3698
+ // Zero survivors from nonempty orders is not an empty history.
3699
+ const diagnostics = [
3700
+ ...dropped.diagnostics(),
3701
+ ...partialRows("heb.orders", "orders", "projection_order_unit_count_mismatch", unitCountMismatches),
3702
+ ];
3703
+ if (sourceOrders.length > 0 && orders.length === 0) {
3704
+ return groceryRejection("heb.orders", diagnostics);
3373
3705
  }
3374
3706
  return {
3375
3707
  ok: true,
@@ -3378,7 +3710,7 @@ const hebOrders = {
3378
3710
  totalOrders: orders.length,
3379
3711
  totalItems: orders.reduce((total, order) => total + order.items.length, 0),
3380
3712
  },
3381
- ...(diagnostics ? { diagnostics } : {}),
3713
+ ...withDiagnostics(diagnostics),
3382
3714
  };
3383
3715
  },
3384
3716
  };
@@ -3472,7 +3804,8 @@ const wholefoodsOrders = {
3472
3804
  },
3473
3805
  primaryKey: { orders: ["id"], order_items: ["id"] },
3474
3806
  lossy: [
3475
- "The retained collector emitted only product-linked named lines; a source line without either field fails this scope as incomplete.",
3807
+ "The retained collector emitted only product-linked named lines; a source line without either field is dropped and counted, and its order is delivered without it.",
3808
+ "An order with no usable line and an item_count that is not a known zero is left out and counted; an order whose line count differs from item_count, or whose item_count is unknown, is delivered and reported partial. A missing or duplicate order id still fails the scope.",
3476
3809
  "Product identity is the source Amazon ASIN in product_id.",
3477
3810
  ],
3478
3811
  provenance: [
@@ -3502,41 +3835,30 @@ const wholefoodsOrders = {
3502
3835
  },
3503
3836
  };
3504
3837
  }
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)) {
3838
+ if (sourceItems.some((item) => !sourceOrderIds.has(item.order_id))) {
3523
3839
  return {
3524
3840
  ok: false,
3525
3841
  error: {
3526
3842
  kind: "incomplete_scope",
3527
3843
  scope: "wholefoods.orders",
3528
- reason: "order item count is missing or does not match the observed lines",
3844
+ reason: "order_items.order_id has no matching order",
3529
3845
  },
3530
3846
  };
3531
3847
  }
3532
- const orders = byStream(records, "orders").flatMap((o) => {
3533
- if (!isNonEmptyString(o.id))
3534
- return [];
3848
+ const dropped = new DroppedRows("wholefoods.orders");
3849
+ let countMismatches = 0;
3850
+ const orders = sourceOrders.flatMap((o) => {
3535
3851
  const items = sourceItems
3536
3852
  .filter((i) => i.order_id === o.id)
3537
3853
  .flatMap((i) => {
3538
- if (!isNonEmptyString(i.name) || !isNonEmptyString(i.product_id))
3854
+ if (!isNonEmptyString(i.name)) {
3855
+ dropped.add("order_items", "projection_order_item_missing_name");
3539
3856
  return [];
3857
+ }
3858
+ if (!isNonEmptyString(i.product_id)) {
3859
+ dropped.add("order_items", "projection_order_item_missing_product_id");
3860
+ return [];
3861
+ }
3540
3862
  return [
3541
3863
  {
3542
3864
  name: i.name,
@@ -3554,6 +3876,18 @@ const wholefoodsOrders = {
3554
3876
  },
3555
3877
  ];
3556
3878
  });
3879
+ // Whole Foods item_count counts lines. Only a known zero proves an
3880
+ // order with no usable line is empty; otherwise it is a detail gap.
3881
+ const itemCount = o.item_count;
3882
+ const knownCount = typeof itemCount === "number" &&
3883
+ Number.isSafeInteger(itemCount) &&
3884
+ itemCount >= 0;
3885
+ if (items.length === 0 && !(knownCount && itemCount === 0)) {
3886
+ dropped.add("orders", GROCERY_ORDER_ITEMS_GAP);
3887
+ return [];
3888
+ }
3889
+ if (!knownCount || items.length !== itemCount)
3890
+ countMismatches += 1;
3557
3891
  return [
3558
3892
  {
3559
3893
  orderId: o.id,
@@ -3578,6 +3912,13 @@ const wholefoodsOrders = {
3578
3912
  },
3579
3913
  ];
3580
3914
  });
3915
+ const diagnostics = [
3916
+ ...dropped.diagnostics(),
3917
+ ...partialRows("wholefoods.orders", "orders", "projection_order_item_count_mismatch", countMismatches),
3918
+ ];
3919
+ if (sourceOrders.length > 0 && orders.length === 0) {
3920
+ return groceryRejection("wholefoods.orders", diagnostics);
3921
+ }
3581
3922
  return {
3582
3923
  ok: true,
3583
3924
  payload: {
@@ -3585,6 +3926,7 @@ const wholefoodsOrders = {
3585
3926
  totalOrders: orders.length,
3586
3927
  totalItems: orders.reduce((sum, o) => sum + o.items.length, 0),
3587
3928
  },
3929
+ ...withDiagnostics(diagnostics),
3588
3930
  };
3589
3931
  },
3590
3932
  };
@@ -3786,10 +4128,16 @@ const icloudNoteFolders = {
3786
4128
  };
3787
4129
  },
3788
4130
  };
4131
+ const ICLOUD_NOTES_PROFILE_PROVENANCE = {
4132
+ kind: "collection-profile",
4133
+ ref: "https://github.com/PDP-Connect/data-connectors/tree/3ac4dd636938fbff9168eb662d2300eb85554801/connectors/icloud_notes",
4134
+ digest: "sha256:ad6d28dac69a84019be2c81ac5f1f2ae8fa9c6602eb179370f0bb9d9022e33b2",
4135
+ path: "declarations/icloud-notes-0.1.24.collection-profile.json#streams[name=profile,notes,folders]",
4136
+ };
3789
4137
  const icloudNotes = {
3790
4138
  scope: "icloud_notes.notes",
3791
4139
  pdppSource: "https://registry.pdpp.dev/connectors/icloud-notes",
3792
- pdppStreams: ["notes", "folders"],
4140
+ pdppStreams: ["notes", "folders", "profile"],
3793
4141
  legacySchemaPath: "icloud_notes.notes.json",
3794
4142
  fieldsRead: {
3795
4143
  notes: [
@@ -3804,27 +4152,29 @@ const icloudNotes = {
3804
4152
  "text_content",
3805
4153
  ],
3806
4154
  folders: ["id", "name"],
4155
+ profile: ["id", "user_name"],
3807
4156
  },
3808
- primaryKey: { notes: ["id"], folders: ["id"] },
4157
+ primaryKey: { notes: ["id"], folders: ["id"], profile: ["id"] },
3809
4158
  lossy: [
3810
- "userName is always null because the declaration has no account/profile stream for the legacy fullName source.",
4159
+ "userName is null when the profile stream has no readable user_name (legacy's fullName default); it is never inferred from an Apple ID or the account id.",
3811
4160
  "folder uses the legacy display-name shape; unresolved folder references fall back to folder_id.",
3812
4161
  ],
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
- ],
4162
+ provenance: [ICLOUD_NOTES_PROFILE_PROVENANCE],
3821
4163
  project(records, options) {
3822
4164
  const guard = requireStreams("icloud_notes.notes", options, [
3823
4165
  "notes",
3824
4166
  "folders",
4167
+ "profile",
3825
4168
  ]);
3826
4169
  if (guard)
3827
4170
  return guard;
4171
+ // The profile stream is a singleton: legacy's fullName maps to exactly one
4172
+ // account. No row, several rows, or an unreadable name leave userName null.
4173
+ const profiles = byStream(records, "profile");
4174
+ const profileName = profiles.length === 1 ? profiles[0]?.user_name : undefined;
4175
+ const userName = typeof profileName === "string" && profileName.trim().length > 0
4176
+ ? profileName
4177
+ : null;
3828
4178
  const folders = new Map();
3829
4179
  for (const folder of byStream(records, "folders")) {
3830
4180
  if (typeof folder.id !== "string" ||
@@ -3890,10 +4240,91 @@ const icloudNotes = {
3890
4240
  }
3891
4241
  return {
3892
4242
  ok: true,
3893
- payload: { notes, total: notes.length, userName: null },
4243
+ payload: { notes, total: notes.length, userName },
3894
4244
  };
3895
4245
  },
3896
4246
  };
4247
+ // ---------------------------------------------------------------------------
4248
+ // strava (strava.activities, strava.coverage_diagnostics)
4249
+ // declarations/strava.collection-profile.json (1.0.8, digest-pinned)
4250
+ // connector_id: https://registry.pdpp.dev/connectors/strava
4251
+ //
4252
+ // Desktop's export served each scope as `{ <stream>: [record.data, ...] }`
4253
+ // (apps/desktop pdpp_export_projection.rs at e970213f). Both bindings are
4254
+ // that identity: every native field, null and unknown field is kept, and the
4255
+ // two streams never read each other.
4256
+ // ---------------------------------------------------------------------------
4257
+ const STRAVA_DIGEST = "sha256:b33086156d3003d0cbe48cb0de4adf48a7fde7241f28c2ea5e350cff1f17c82c";
4258
+ const STRAVA_FIELDS = {
4259
+ activities: [
4260
+ "id",
4261
+ "activity_type",
4262
+ "start_date",
4263
+ "start_time",
4264
+ "start_time_basis",
4265
+ "distance_m",
4266
+ "moving_time_s",
4267
+ "elapsed_time_s",
4268
+ "total_elevation_gain_m",
4269
+ "average_heartrate",
4270
+ "max_heartrate",
4271
+ "calories_kcal",
4272
+ "gear",
4273
+ "freshness",
4274
+ "exported_at",
4275
+ ],
4276
+ coverage_diagnostics: [
4277
+ "id",
4278
+ "stream",
4279
+ "status",
4280
+ "reason",
4281
+ "record_count",
4282
+ "fields_unavailable",
4283
+ "window_requested_from",
4284
+ "window_requested_to",
4285
+ "window_covered_from",
4286
+ "window_covered_to",
4287
+ "freshness",
4288
+ "exported_at",
4289
+ ],
4290
+ };
4291
+ function stravaIdentity(stream, lossy) {
4292
+ const scope = `strava.${stream}`;
4293
+ return {
4294
+ scope,
4295
+ pdppSource: "https://registry.pdpp.dev/connectors/strava",
4296
+ pdppStreams: [stream],
4297
+ legacySchemaPath: `${scope}.json`,
4298
+ fieldsRead: { [stream]: [...STRAVA_FIELDS[stream]] },
4299
+ primaryKey: { [stream]: ["id"] },
4300
+ lossy,
4301
+ rowOrder: "arrival",
4302
+ provenance: [
4303
+ {
4304
+ kind: "oci",
4305
+ ref: "ghcr.io/pdp-connect/connector/strava:1.0.8",
4306
+ digest: STRAVA_DIGEST,
4307
+ path: `declarations/strava.collection-profile.json#streams[name=${stream}]`,
4308
+ },
4309
+ ],
4310
+ project(records, options) {
4311
+ const guard = requireStreams(scope, options, [stream]);
4312
+ if (guard)
4313
+ return guard;
4314
+ return { ok: true, payload: { [stream]: byStream(records, stream) } };
4315
+ },
4316
+ };
4317
+ }
4318
+ const stravaActivities = stravaIdentity("activities", [
4319
+ "No field is dropped, derived or defaulted: the body is the native records.",
4320
+ "Row order: Desktop's snapshot was in arrival order (a re-emitted id, changed or not, moved to the end). The binding keeps its input order; the read facade supplies arrival order and reports rows it cannot place.",
4321
+ "Coverage: the body carries rows only, as Desktop's did. Whether the export was read in full is producer data in strava.coverage_diagnostics; this scope neither reads nor judges it.",
4322
+ ]);
4323
+ const stravaCoverageDiagnostics = stravaIdentity("coverage_diagnostics", [
4324
+ "No field is dropped, derived or defaulted: the body is the native records, so a partial or empty status, its reason and fields_unavailable reach the reader as written.",
4325
+ "Row order: as for strava.activities.",
4326
+ "Absence of a row is not evidence of a complete collection: the producer emits no diagnostic when only diagnostics are requested and the import directory exists. The read facade serves an empty list only with that stream's own completion receipt.",
4327
+ ]);
3897
4328
  export const LEGACY_SCOPE_BINDINGS = new Map(Object.entries({
3898
4329
  "instagram.profile": instagramProfile,
3899
4330
  "instagram.posts": instagramPosts,
@@ -3938,6 +4369,8 @@ export const LEGACY_SCOPE_BINDINGS = new Map(Object.entries({
3938
4369
  "youtube.history": youtubeHistory,
3939
4370
  "claude.conversations": claudeConversations,
3940
4371
  "claude.projects": claudeProjects,
4372
+ "strava.activities": stravaActivities,
4373
+ "strava.coverage_diagnostics": stravaCoverageDiagnostics,
3941
4374
  }));
3942
4375
  /** Known legacy contracts checked against signed declarations but not
3943
4376
  * projectable without fabricating fields or changing the retained contract. */