@noy-db/hub 0.1.0-pre.10

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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +197 -0
  3. package/dist/aggregate/index.cjs +476 -0
  4. package/dist/aggregate/index.cjs.map +1 -0
  5. package/dist/aggregate/index.d.cts +38 -0
  6. package/dist/aggregate/index.d.ts +38 -0
  7. package/dist/aggregate/index.js +53 -0
  8. package/dist/aggregate/index.js.map +1 -0
  9. package/dist/blobs/index.cjs +1480 -0
  10. package/dist/blobs/index.cjs.map +1 -0
  11. package/dist/blobs/index.d.cts +45 -0
  12. package/dist/blobs/index.d.ts +45 -0
  13. package/dist/blobs/index.js +48 -0
  14. package/dist/blobs/index.js.map +1 -0
  15. package/dist/bundle/index.cjs +496 -0
  16. package/dist/bundle/index.cjs.map +1 -0
  17. package/dist/bundle/index.d.cts +7 -0
  18. package/dist/bundle/index.d.ts +7 -0
  19. package/dist/bundle/index.js +51 -0
  20. package/dist/bundle/index.js.map +1 -0
  21. package/dist/chunk-2QR2PQTT.js +217 -0
  22. package/dist/chunk-2QR2PQTT.js.map +1 -0
  23. package/dist/chunk-72UIIX3E.js +1109 -0
  24. package/dist/chunk-72UIIX3E.js.map +1 -0
  25. package/dist/chunk-A4NFZKRW.js +722 -0
  26. package/dist/chunk-A4NFZKRW.js.map +1 -0
  27. package/dist/chunk-AOYCZP2H.js +793 -0
  28. package/dist/chunk-AOYCZP2H.js.map +1 -0
  29. package/dist/chunk-CIMZBAZB.js +72 -0
  30. package/dist/chunk-CIMZBAZB.js.map +1 -0
  31. package/dist/chunk-E3AGCGJ4.js +160 -0
  32. package/dist/chunk-E3AGCGJ4.js.map +1 -0
  33. package/dist/chunk-EKX3YVCI.js +97 -0
  34. package/dist/chunk-EKX3YVCI.js.map +1 -0
  35. package/dist/chunk-EMIGCR7X.js +39 -0
  36. package/dist/chunk-EMIGCR7X.js.map +1 -0
  37. package/dist/chunk-EMMRIE3C.js +72 -0
  38. package/dist/chunk-EMMRIE3C.js.map +1 -0
  39. package/dist/chunk-EUNIORPU.js +680 -0
  40. package/dist/chunk-EUNIORPU.js.map +1 -0
  41. package/dist/chunk-FZU343FL.js +32 -0
  42. package/dist/chunk-FZU343FL.js.map +1 -0
  43. package/dist/chunk-GHGXG53C.js +795 -0
  44. package/dist/chunk-GHGXG53C.js.map +1 -0
  45. package/dist/chunk-GKA4BGJN.js +79 -0
  46. package/dist/chunk-GKA4BGJN.js.map +1 -0
  47. package/dist/chunk-HG2OWBLX.js +430 -0
  48. package/dist/chunk-HG2OWBLX.js.map +1 -0
  49. package/dist/chunk-IGAROPKM.js +34 -0
  50. package/dist/chunk-IGAROPKM.js.map +1 -0
  51. package/dist/chunk-J66GRPNH.js +111 -0
  52. package/dist/chunk-J66GRPNH.js.map +1 -0
  53. package/dist/chunk-LVMMDXFT.js +275 -0
  54. package/dist/chunk-LVMMDXFT.js.map +1 -0
  55. package/dist/chunk-M5INGEFC.js +84 -0
  56. package/dist/chunk-M5INGEFC.js.map +1 -0
  57. package/dist/chunk-NBYQNDXA.js +557 -0
  58. package/dist/chunk-NBYQNDXA.js.map +1 -0
  59. package/dist/chunk-NPC4LFV5.js +132 -0
  60. package/dist/chunk-NPC4LFV5.js.map +1 -0
  61. package/dist/chunk-NSWHB5VQ.js +1285 -0
  62. package/dist/chunk-NSWHB5VQ.js.map +1 -0
  63. package/dist/chunk-OLM4LA6K.js +392 -0
  64. package/dist/chunk-OLM4LA6K.js.map +1 -0
  65. package/dist/chunk-UAFBZWFB.js +155 -0
  66. package/dist/chunk-UAFBZWFB.js.map +1 -0
  67. package/dist/chunk-UF3BUNQZ.js +1 -0
  68. package/dist/chunk-UF3BUNQZ.js.map +1 -0
  69. package/dist/chunk-UMMAVAYW.js +17 -0
  70. package/dist/chunk-UMMAVAYW.js.map +1 -0
  71. package/dist/chunk-UPY7WLBH.js +381 -0
  72. package/dist/chunk-UPY7WLBH.js.map +1 -0
  73. package/dist/chunk-W63BWEJH.js +311 -0
  74. package/dist/chunk-W63BWEJH.js.map +1 -0
  75. package/dist/chunk-WIGI5OJK.js +90 -0
  76. package/dist/chunk-WIGI5OJK.js.map +1 -0
  77. package/dist/chunk-XNL2TKKR.js +490 -0
  78. package/dist/chunk-XNL2TKKR.js.map +1 -0
  79. package/dist/chunk-XWNUJPIS.js +367 -0
  80. package/dist/chunk-XWNUJPIS.js.map +1 -0
  81. package/dist/chunk-YWKJZZGV.js +715 -0
  82. package/dist/chunk-YWKJZZGV.js.map +1 -0
  83. package/dist/consent/index.cjs +204 -0
  84. package/dist/consent/index.cjs.map +1 -0
  85. package/dist/consent/index.d.cts +24 -0
  86. package/dist/consent/index.d.ts +24 -0
  87. package/dist/consent/index.js +23 -0
  88. package/dist/consent/index.js.map +1 -0
  89. package/dist/crdt/index.cjs +152 -0
  90. package/dist/crdt/index.cjs.map +1 -0
  91. package/dist/crdt/index.d.cts +30 -0
  92. package/dist/crdt/index.d.ts +30 -0
  93. package/dist/crdt/index.js +24 -0
  94. package/dist/crdt/index.js.map +1 -0
  95. package/dist/crypto-6PNIHP7W.js +44 -0
  96. package/dist/crypto-6PNIHP7W.js.map +1 -0
  97. package/dist/delegation-WVIVMF73.js +17 -0
  98. package/dist/delegation-WVIVMF73.js.map +1 -0
  99. package/dist/dev-unlock-D4xB0_gs.d.cts +263 -0
  100. package/dist/dev-unlock-Dz8GEbd3.d.ts +263 -0
  101. package/dist/hash--EflSV65.d.cts +63 -0
  102. package/dist/hash-CRdXYnv3.d.ts +63 -0
  103. package/dist/history/index.cjs +1215 -0
  104. package/dist/history/index.cjs.map +1 -0
  105. package/dist/history/index.d.cts +62 -0
  106. package/dist/history/index.d.ts +62 -0
  107. package/dist/history/index.js +79 -0
  108. package/dist/history/index.js.map +1 -0
  109. package/dist/i18n/index.cjs +840 -0
  110. package/dist/i18n/index.cjs.map +1 -0
  111. package/dist/i18n/index.d.cts +38 -0
  112. package/dist/i18n/index.d.ts +38 -0
  113. package/dist/i18n/index.js +68 -0
  114. package/dist/i18n/index.js.map +1 -0
  115. package/dist/index-CD1VnONm.d.cts +415 -0
  116. package/dist/index-CLRxPs-W.d.cts +1960 -0
  117. package/dist/index-CUi9wfss.d.ts +415 -0
  118. package/dist/index-DtV93TMP.d.ts +1960 -0
  119. package/dist/index.cjs +17387 -0
  120. package/dist/index.cjs.map +1 -0
  121. package/dist/index.d.cts +565 -0
  122. package/dist/index.d.ts +565 -0
  123. package/dist/index.js +7525 -0
  124. package/dist/index.js.map +1 -0
  125. package/dist/indexing/index.cjs +736 -0
  126. package/dist/indexing/index.cjs.map +1 -0
  127. package/dist/indexing/index.d.cts +36 -0
  128. package/dist/indexing/index.d.ts +36 -0
  129. package/dist/indexing/index.js +77 -0
  130. package/dist/indexing/index.js.map +1 -0
  131. package/dist/lazy-builder-BwEoBQZ9.d.ts +304 -0
  132. package/dist/lazy-builder-CZVLKh0Z.d.cts +304 -0
  133. package/dist/ledger-HBBH2NPZ.js +33 -0
  134. package/dist/ledger-HBBH2NPZ.js.map +1 -0
  135. package/dist/mime-magic-CBBSOkjm.d.cts +50 -0
  136. package/dist/mime-magic-CBBSOkjm.d.ts +50 -0
  137. package/dist/periods/index.cjs +1035 -0
  138. package/dist/periods/index.cjs.map +1 -0
  139. package/dist/periods/index.d.cts +21 -0
  140. package/dist/periods/index.d.ts +21 -0
  141. package/dist/periods/index.js +25 -0
  142. package/dist/periods/index.js.map +1 -0
  143. package/dist/predicate-SBHmi6D0.d.cts +161 -0
  144. package/dist/predicate-SBHmi6D0.d.ts +161 -0
  145. package/dist/public-envelope-TLQA6REO.js +31 -0
  146. package/dist/public-envelope-TLQA6REO.js.map +1 -0
  147. package/dist/query/index.cjs +1999 -0
  148. package/dist/query/index.cjs.map +1 -0
  149. package/dist/query/index.d.cts +3 -0
  150. package/dist/query/index.d.ts +3 -0
  151. package/dist/query/index.js +73 -0
  152. package/dist/query/index.js.map +1 -0
  153. package/dist/session/index.cjs +495 -0
  154. package/dist/session/index.cjs.map +1 -0
  155. package/dist/session/index.d.cts +45 -0
  156. package/dist/session/index.d.ts +45 -0
  157. package/dist/session/index.js +51 -0
  158. package/dist/session/index.js.map +1 -0
  159. package/dist/shadow/index.cjs +133 -0
  160. package/dist/shadow/index.cjs.map +1 -0
  161. package/dist/shadow/index.d.cts +16 -0
  162. package/dist/shadow/index.d.ts +16 -0
  163. package/dist/shadow/index.js +20 -0
  164. package/dist/shadow/index.js.map +1 -0
  165. package/dist/store/index.cjs +1083 -0
  166. package/dist/store/index.cjs.map +1 -0
  167. package/dist/store/index.d.cts +491 -0
  168. package/dist/store/index.d.ts +491 -0
  169. package/dist/store/index.js +37 -0
  170. package/dist/store/index.js.map +1 -0
  171. package/dist/strategy-BSxFXGzb.d.cts +110 -0
  172. package/dist/strategy-BSxFXGzb.d.ts +110 -0
  173. package/dist/strategy-D-SrOLCl.d.cts +548 -0
  174. package/dist/strategy-D-SrOLCl.d.ts +548 -0
  175. package/dist/sync/index.cjs +1062 -0
  176. package/dist/sync/index.cjs.map +1 -0
  177. package/dist/sync/index.d.cts +42 -0
  178. package/dist/sync/index.d.ts +42 -0
  179. package/dist/sync/index.js +28 -0
  180. package/dist/sync/index.js.map +1 -0
  181. package/dist/team/index.cjs +2606 -0
  182. package/dist/team/index.cjs.map +1 -0
  183. package/dist/team/index.d.cts +117 -0
  184. package/dist/team/index.d.ts +117 -0
  185. package/dist/team/index.js +106 -0
  186. package/dist/team/index.js.map +1 -0
  187. package/dist/tx/index.cjs +212 -0
  188. package/dist/tx/index.cjs.map +1 -0
  189. package/dist/tx/index.d.cts +20 -0
  190. package/dist/tx/index.d.ts +20 -0
  191. package/dist/tx/index.js +20 -0
  192. package/dist/tx/index.js.map +1 -0
  193. package/dist/types-DSFLtbKg.d.ts +9702 -0
  194. package/dist/types-zwwMOqkg.d.cts +9702 -0
  195. package/dist/ulid-COREQ2RQ.js +9 -0
  196. package/dist/ulid-COREQ2RQ.js.map +1 -0
  197. package/dist/util/index.cjs +230 -0
  198. package/dist/util/index.cjs.map +1 -0
  199. package/dist/util/index.d.cts +77 -0
  200. package/dist/util/index.d.ts +77 -0
  201. package/dist/util/index.js +190 -0
  202. package/dist/util/index.js.map +1 -0
  203. package/package.json +244 -0
@@ -0,0 +1,1999 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/query/index.ts
21
+ var query_exports = {};
22
+ __export(query_exports, {
23
+ Aggregation: () => Aggregation,
24
+ CollectionIndexes: () => CollectionIndexes,
25
+ DEFAULT_JOIN_MAX_ROWS: () => DEFAULT_JOIN_MAX_ROWS,
26
+ DanglingReferenceError: () => DanglingReferenceError,
27
+ GROUPBY_MAX_CARDINALITY: () => GROUPBY_MAX_CARDINALITY,
28
+ GROUPBY_WARN_CARDINALITY: () => GROUPBY_WARN_CARDINALITY,
29
+ GroupCardinalityError: () => GroupCardinalityError,
30
+ GroupedAggregation: () => GroupedAggregation,
31
+ GroupedQuery: () => GroupedQuery,
32
+ IndexRequiredError: () => IndexRequiredError,
33
+ IndexWriteFailureError: () => IndexWriteFailureError,
34
+ JoinTooLargeError: () => JoinTooLargeError,
35
+ Query: () => Query,
36
+ ScanBuilder: () => ScanBuilder,
37
+ applyJoins: () => applyJoins,
38
+ avg: () => avg,
39
+ buildLiveAggregation: () => buildLiveAggregation,
40
+ buildLiveQuery: () => buildLiveQuery,
41
+ count: () => count,
42
+ evaluateClause: () => evaluateClause,
43
+ evaluateFieldClause: () => evaluateFieldClause,
44
+ executePlan: () => executePlan,
45
+ groupAndReduce: () => groupAndReduce,
46
+ max: () => max,
47
+ min: () => min,
48
+ readPath: () => readPath,
49
+ reduceRecords: () => reduceRecords,
50
+ resetGroupByWarnings: () => resetGroupByWarnings,
51
+ resetJoinWarnings: () => resetJoinWarnings,
52
+ sum: () => sum
53
+ });
54
+ module.exports = __toCommonJS(query_exports);
55
+
56
+ // src/query/predicate.ts
57
+ function readPath(record, path) {
58
+ if (record === null || record === void 0) return void 0;
59
+ if (!path.includes(".")) {
60
+ return record[path];
61
+ }
62
+ const segments = path.split(".");
63
+ let cursor = record;
64
+ for (const segment of segments) {
65
+ if (cursor === null || cursor === void 0) return void 0;
66
+ cursor = cursor[segment];
67
+ }
68
+ return cursor;
69
+ }
70
+ function evaluateFieldClause(record, clause) {
71
+ const actual = readPath(record, clause.field);
72
+ const { op, value } = clause;
73
+ switch (op) {
74
+ case "==":
75
+ return actual === value;
76
+ case "!=":
77
+ return actual !== value;
78
+ case "<":
79
+ return isComparable(actual, value) && actual < value;
80
+ case "<=":
81
+ return isComparable(actual, value) && actual <= value;
82
+ case ">":
83
+ return isComparable(actual, value) && actual > value;
84
+ case ">=":
85
+ return isComparable(actual, value) && actual >= value;
86
+ case "in":
87
+ return Array.isArray(value) && value.includes(actual);
88
+ case "contains":
89
+ if (typeof actual === "string") return typeof value === "string" && actual.includes(value);
90
+ if (Array.isArray(actual)) return actual.includes(value);
91
+ return false;
92
+ case "startsWith":
93
+ return typeof actual === "string" && typeof value === "string" && actual.startsWith(value);
94
+ case "between": {
95
+ if (!Array.isArray(value) || value.length !== 2) return false;
96
+ const [lo, hi] = value;
97
+ if (!isComparable(actual, lo) || !isComparable(actual, hi)) return false;
98
+ return actual >= lo && actual <= hi;
99
+ }
100
+ default: {
101
+ const _exhaustive = op;
102
+ void _exhaustive;
103
+ return false;
104
+ }
105
+ }
106
+ }
107
+ function isComparable(a, b) {
108
+ if (typeof a === "number" && typeof b === "number") return true;
109
+ if (typeof a === "string" && typeof b === "string") return true;
110
+ if (a instanceof Date && b instanceof Date) return true;
111
+ return false;
112
+ }
113
+ function evaluateClause(record, clause) {
114
+ switch (clause.type) {
115
+ case "field":
116
+ return evaluateFieldClause(record, clause);
117
+ case "filter":
118
+ return clause.fn(record);
119
+ case "group":
120
+ if (clause.op === "and") {
121
+ for (const child of clause.clauses) {
122
+ if (!evaluateClause(record, child)) return false;
123
+ }
124
+ return true;
125
+ } else {
126
+ for (const child of clause.clauses) {
127
+ if (evaluateClause(record, child)) return true;
128
+ }
129
+ return false;
130
+ }
131
+ }
132
+ }
133
+
134
+ // src/errors.ts
135
+ var NoydbError = class extends Error {
136
+ /** Machine-readable error code. Stable across library versions. */
137
+ code;
138
+ constructor(code, message) {
139
+ super(message);
140
+ this.name = "NoydbError";
141
+ this.code = code;
142
+ }
143
+ };
144
+ var GroupCardinalityError = class extends NoydbError {
145
+ /** The field being grouped on. */
146
+ field;
147
+ /** Observed number of distinct groups at the moment the cap tripped. */
148
+ cardinality;
149
+ /** The cap that was exceeded. */
150
+ maxGroups;
151
+ constructor(field, cardinality, maxGroups) {
152
+ super(
153
+ "GROUP_CARDINALITY",
154
+ `.groupBy("${field}") produced ${cardinality} distinct groups, exceeding the ${maxGroups}-group ceiling. This is almost always a query mistake \u2014 grouping on a high-uniqueness field like "id" or "createdAt" produces one bucket per record. Narrow the query with .where() before grouping, or group on a lower-cardinality field (status, category, clientId). If you genuinely need high-cardinality grouping, file an issue with your use case.`
155
+ );
156
+ this.name = "GroupCardinalityError";
157
+ this.field = field;
158
+ this.cardinality = cardinality;
159
+ this.maxGroups = maxGroups;
160
+ }
161
+ };
162
+ var IndexRequiredError = class extends NoydbError {
163
+ collection;
164
+ touchedFields;
165
+ missingFields;
166
+ constructor(args) {
167
+ super(
168
+ "INDEX_REQUIRED",
169
+ `Collection "${args.collection}": query references unindexed fields in lazy mode (missing: ${args.missingFields.join(", ")}). Declare an index on each field, or use collection.scan() for non-indexed iteration.`
170
+ );
171
+ this.name = "IndexRequiredError";
172
+ this.collection = args.collection;
173
+ this.touchedFields = [...args.touchedFields];
174
+ this.missingFields = [...args.missingFields];
175
+ }
176
+ };
177
+ var IndexWriteFailureError = class extends NoydbError {
178
+ recordId;
179
+ field;
180
+ op;
181
+ cause;
182
+ constructor(args) {
183
+ super(
184
+ "INDEX_WRITE_FAILURE",
185
+ `Index side-car ${args.op} failed for field "${args.field}" on record "${args.recordId}"`
186
+ );
187
+ this.name = "IndexWriteFailureError";
188
+ this.recordId = args.recordId;
189
+ this.field = args.field;
190
+ this.op = args.op;
191
+ this.cause = args.cause;
192
+ }
193
+ };
194
+ var JoinTooLargeError = class extends NoydbError {
195
+ leftRows;
196
+ rightRows;
197
+ maxRows;
198
+ side;
199
+ constructor(opts) {
200
+ super("JOIN_TOO_LARGE", opts.message);
201
+ this.name = "JoinTooLargeError";
202
+ this.leftRows = opts.leftRows;
203
+ this.rightRows = opts.rightRows;
204
+ this.maxRows = opts.maxRows;
205
+ this.side = opts.side;
206
+ }
207
+ };
208
+ var DanglingReferenceError = class extends NoydbError {
209
+ field;
210
+ target;
211
+ refId;
212
+ constructor(opts) {
213
+ super("DANGLING_REFERENCE", opts.message);
214
+ this.name = "DanglingReferenceError";
215
+ this.field = opts.field;
216
+ this.target = opts.target;
217
+ this.refId = opts.refId;
218
+ }
219
+ };
220
+
221
+ // src/query/join.ts
222
+ var DEFAULT_JOIN_MAX_ROWS = 5e4;
223
+ var JOIN_WARN_FRACTION = 0.8;
224
+ function coerceRefKey(value) {
225
+ if (value === null || value === void 0) return null;
226
+ if (typeof value === "string") return value;
227
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
228
+ return null;
229
+ }
230
+ var warnedDanglingKeys = /* @__PURE__ */ new Set();
231
+ function warnOnceDangling(field, target, refId) {
232
+ const key = `${field}\u2192${target}:${refId}`;
233
+ if (warnedDanglingKeys.has(key)) return;
234
+ warnedDanglingKeys.add(key);
235
+ console.warn(
236
+ `[noy-db] .join() encountered dangling ref in 'warn' mode: field "${field}" \u2192 "${target}:${refId}" not found. Attaching null.`
237
+ );
238
+ }
239
+ var warnedCeilingKeys = /* @__PURE__ */ new Set();
240
+ function warnCeilingApproaching(target, side, rows, maxRows) {
241
+ const key = `${target}:${side}`;
242
+ if (warnedCeilingKeys.has(key)) return;
243
+ warnedCeilingKeys.add(key);
244
+ const pct = Math.round(rows / maxRows * 100);
245
+ console.warn(
246
+ `[noy-db] .join() ${side} side is at ${pct}% of the ${maxRows}-row ceiling for target "${target}" (${rows} rows). Streaming joins over scan() are not yet supported for collections that need to exceed this.`
247
+ );
248
+ }
249
+ function applyJoins(rows, joins, context) {
250
+ if (joins.length === 0) return [...rows];
251
+ let result = [...rows];
252
+ for (const leg of joins) {
253
+ result = applyOneJoin(result, leg, context);
254
+ }
255
+ return result;
256
+ }
257
+ function applyOneJoin(leftRows, leg, context) {
258
+ if (leg.isDictJoin) {
259
+ const dictSource = context.resolveDictSource?.(leg.field);
260
+ if (!dictSource) {
261
+ throw new Error(
262
+ `.join() field "${leg.field}" on "${context.leftCollection}" is declared as a dictKey join but the dict source could not be resolved. Ensure the dictionary has at least one entry.`
263
+ );
264
+ }
265
+ const out = [];
266
+ const snapshot = dictSource.snapshot();
267
+ const dictMap = /* @__PURE__ */ new Map();
268
+ for (const entry of snapshot) {
269
+ const k = readPath(entry, "key");
270
+ if (typeof k === "string") dictMap.set(k, entry);
271
+ }
272
+ for (const left of leftRows) {
273
+ const rawId = readPath(left, leg.field);
274
+ const key = coerceRefKey(rawId);
275
+ const dictEntry = key === null ? void 0 : dictMap.get(key);
276
+ out.push({ ...left, [leg.as]: dictEntry ?? null });
277
+ }
278
+ return out;
279
+ }
280
+ const source = context.resolveSource(leg.target);
281
+ if (!source) {
282
+ throw new Error(
283
+ `.join() cannot resolve target collection "${leg.target}" (referenced from field "${leg.field}" on "${context.leftCollection}"). Make sure the target collection has been opened via vault.collection() at least once before running the query.`
284
+ );
285
+ }
286
+ const maxRows = leg.maxRows ?? DEFAULT_JOIN_MAX_ROWS;
287
+ if (leftRows.length > maxRows) {
288
+ throw new JoinTooLargeError({
289
+ leftRows: leftRows.length,
290
+ rightRows: -1,
291
+ maxRows,
292
+ side: "left",
293
+ message: `.join() left side has ${leftRows.length} rows, exceeding the ${maxRows}-row ceiling for target "${leg.target}". Filter the left side further with where()/limit() before joining, or raise the ceiling via { maxRows }. Streaming joins over scan() are not yet supported.`
294
+ });
295
+ }
296
+ if (leftRows.length > maxRows * JOIN_WARN_FRACTION) {
297
+ warnCeilingApproaching(leg.target, "left", leftRows.length, maxRows);
298
+ }
299
+ const rightSnapshot = source.snapshot();
300
+ if (rightSnapshot.length > maxRows) {
301
+ throw new JoinTooLargeError({
302
+ leftRows: leftRows.length,
303
+ rightRows: rightSnapshot.length,
304
+ maxRows,
305
+ side: "right",
306
+ message: `.join() right side "${leg.target}" has ${rightSnapshot.length} rows, exceeding the ${maxRows}-row ceiling. Raise the ceiling via { maxRows } if the data genuinely fits in memory, or track for streaming joins.`
307
+ });
308
+ }
309
+ if (rightSnapshot.length > maxRows * JOIN_WARN_FRACTION) {
310
+ warnCeilingApproaching(leg.target, "right", rightSnapshot.length, maxRows);
311
+ }
312
+ const strategy = leg.strategy ?? (source.lookupById ? "nested" : "hash");
313
+ if (strategy === "nested" && source.lookupById) {
314
+ const lookup = (id) => source.lookupById?.(id);
315
+ return nestedLoopJoin(leftRows, leg, lookup);
316
+ }
317
+ return hashJoin(leftRows, leg, rightSnapshot);
318
+ }
319
+ function nestedLoopJoin(leftRows, leg, lookupById) {
320
+ const out = [];
321
+ for (const left of leftRows) {
322
+ const rawId = readPath(left, leg.field);
323
+ const key = coerceRefKey(rawId);
324
+ const right = key === null ? void 0 : lookupById(key);
325
+ out.push(attachJoin(left, leg, right, rawId));
326
+ }
327
+ return out;
328
+ }
329
+ function hashJoin(leftRows, leg, rightSnapshot) {
330
+ const rightMap = /* @__PURE__ */ new Map();
331
+ for (const record of rightSnapshot) {
332
+ const rawId = readPath(record, "id");
333
+ const key = coerceRefKey(rawId);
334
+ if (key !== null) {
335
+ rightMap.set(key, record);
336
+ }
337
+ }
338
+ const out = [];
339
+ for (const left of leftRows) {
340
+ const rawId = readPath(left, leg.field);
341
+ const key = coerceRefKey(rawId);
342
+ const right = key === null ? void 0 : rightMap.get(key);
343
+ out.push(attachJoin(left, leg, right, rawId));
344
+ }
345
+ return out;
346
+ }
347
+ function attachJoin(left, leg, right, rawId) {
348
+ if (left === null || typeof left !== "object") {
349
+ return left;
350
+ }
351
+ const merged = { ...left };
352
+ const refKey = coerceRefKey(rawId);
353
+ if (right === void 0) {
354
+ if (refKey !== null && leg.mode === "strict") {
355
+ throw new DanglingReferenceError({
356
+ field: leg.field,
357
+ target: leg.target,
358
+ refId: refKey,
359
+ message: `.join() strict dangling: record references "${leg.target}:${refKey}" via field "${leg.field}", but no such record exists. Use ref() mode 'warn' or 'cascade' if dangling refs are acceptable, or run vault.checkIntegrity() to find and fix the orphans.`
360
+ });
361
+ }
362
+ if (refKey !== null && leg.mode === "warn") {
363
+ warnOnceDangling(leg.field, leg.target, refKey);
364
+ }
365
+ merged[leg.as] = null;
366
+ } else {
367
+ merged[leg.as] = right;
368
+ }
369
+ return merged;
370
+ }
371
+ function resetJoinWarnings() {
372
+ warnedDanglingKeys.clear();
373
+ warnedCeilingKeys.clear();
374
+ }
375
+
376
+ // src/query/live.ts
377
+ function buildLiveQuery(recompute, upstreams) {
378
+ return new LiveQueryImpl(recompute, upstreams);
379
+ }
380
+ var LiveQueryImpl = class {
381
+ constructor(recompute, upstreams) {
382
+ this.recompute = recompute;
383
+ this.refresh();
384
+ for (const upstream of upstreams) {
385
+ try {
386
+ this.unsubs.push(upstream.subscribe(this.onUpstreamChange));
387
+ } catch (err) {
388
+ this._error = err instanceof Error ? err : new Error(String(err));
389
+ }
390
+ }
391
+ }
392
+ recompute;
393
+ _value = [];
394
+ _error = null;
395
+ listeners = /* @__PURE__ */ new Set();
396
+ unsubs = [];
397
+ stopped = false;
398
+ get value() {
399
+ return this._value;
400
+ }
401
+ get error() {
402
+ return this._error;
403
+ }
404
+ /**
405
+ * Bound change handler — used as the callback passed to every
406
+ * upstream's subscribe. Bound via class field so the `this`
407
+ * context survives the indirect call from arbitrary upstreams.
408
+ */
409
+ onUpstreamChange = () => {
410
+ this.refresh();
411
+ for (const cb of this.listeners) {
412
+ try {
413
+ cb();
414
+ } catch {
415
+ }
416
+ }
417
+ };
418
+ refresh() {
419
+ if (this.stopped) return;
420
+ try {
421
+ this._value = this.recompute();
422
+ this._error = null;
423
+ } catch (err) {
424
+ this._error = err instanceof Error ? err : new Error(String(err));
425
+ }
426
+ }
427
+ subscribe(cb) {
428
+ if (this.stopped) return () => {
429
+ };
430
+ this.listeners.add(cb);
431
+ return () => this.listeners.delete(cb);
432
+ }
433
+ stop() {
434
+ if (this.stopped) return;
435
+ this.stopped = true;
436
+ for (const unsub of this.unsubs) {
437
+ try {
438
+ unsub();
439
+ } catch {
440
+ }
441
+ }
442
+ this.unsubs.length = 0;
443
+ this.listeners.clear();
444
+ }
445
+ };
446
+
447
+ // src/aggregate/strategy.ts
448
+ var NOT_ENABLED = new Error(
449
+ 'Aggregate / groupBy is not enabled on this Noydb instance. Import `{ withAggregate }` from "@noy-db/hub/aggregate" and pass it to `createNoydb({ aggregateStrategy: withAggregate() })`.'
450
+ );
451
+ var NO_AGGREGATE = {
452
+ aggregate() {
453
+ throw NOT_ENABLED;
454
+ },
455
+ groupBy() {
456
+ throw NOT_ENABLED;
457
+ },
458
+ scanAggregate() {
459
+ throw NOT_ENABLED;
460
+ }
461
+ };
462
+
463
+ // src/query/builder.ts
464
+ var EMPTY_PLAN = {
465
+ clauses: [],
466
+ orderBy: [],
467
+ limit: void 0,
468
+ offset: 0,
469
+ joins: []
470
+ };
471
+ var Query = class _Query {
472
+ source;
473
+ plan;
474
+ joinContext;
475
+ aggregateStrategy;
476
+ constructor(source, plan = EMPTY_PLAN, joinContext, aggregateStrategy = NO_AGGREGATE) {
477
+ this.source = source;
478
+ this.plan = plan;
479
+ this.joinContext = joinContext;
480
+ this.aggregateStrategy = aggregateStrategy;
481
+ }
482
+ /** Add a field comparison. Multiple where() calls are AND-combined. */
483
+ where(field, op, value) {
484
+ const clause = { type: "field", field, op, value };
485
+ return new _Query(
486
+ this.source,
487
+ { ...this.plan, clauses: [...this.plan.clauses, clause] },
488
+ this.joinContext,
489
+ this.aggregateStrategy
490
+ );
491
+ }
492
+ /**
493
+ * Logical OR group. Pass a callback that builds a sub-query.
494
+ * Each clause inside the callback is OR-combined; the group itself
495
+ * joins the parent plan with AND.
496
+ */
497
+ or(builder) {
498
+ const sub = builder(
499
+ new _Query(this.source, EMPTY_PLAN, this.joinContext, this.aggregateStrategy)
500
+ );
501
+ const group = {
502
+ type: "group",
503
+ op: "or",
504
+ clauses: sub.plan.clauses
505
+ };
506
+ return new _Query(
507
+ this.source,
508
+ { ...this.plan, clauses: [...this.plan.clauses, group] },
509
+ this.joinContext,
510
+ this.aggregateStrategy
511
+ );
512
+ }
513
+ /**
514
+ * Logical AND group. Same shape as `or()` but every clause inside the group
515
+ * must match. Useful for explicit grouping inside a larger OR.
516
+ */
517
+ and(builder) {
518
+ const sub = builder(
519
+ new _Query(this.source, EMPTY_PLAN, this.joinContext, this.aggregateStrategy)
520
+ );
521
+ const group = {
522
+ type: "group",
523
+ op: "and",
524
+ clauses: sub.plan.clauses
525
+ };
526
+ return new _Query(
527
+ this.source,
528
+ { ...this.plan, clauses: [...this.plan.clauses, group] },
529
+ this.joinContext,
530
+ this.aggregateStrategy
531
+ );
532
+ }
533
+ /** Escape hatch: add an arbitrary predicate function. Not serializable. */
534
+ filter(fn) {
535
+ const clause = {
536
+ type: "filter",
537
+ fn
538
+ };
539
+ return new _Query(
540
+ this.source,
541
+ { ...this.plan, clauses: [...this.plan.clauses, clause] },
542
+ this.joinContext,
543
+ this.aggregateStrategy
544
+ );
545
+ }
546
+ /** Sort by a field. Subsequent calls are tie-breakers. */
547
+ orderBy(field, direction = "asc") {
548
+ return new _Query(
549
+ this.source,
550
+ { ...this.plan, orderBy: [...this.plan.orderBy, { field, direction }] },
551
+ this.joinContext,
552
+ this.aggregateStrategy
553
+ );
554
+ }
555
+ /** Cap the result size. */
556
+ limit(n) {
557
+ return new _Query(
558
+ this.source,
559
+ { ...this.plan, limit: n },
560
+ this.joinContext,
561
+ this.aggregateStrategy
562
+ );
563
+ }
564
+ /** Skip the first N matching records (after ordering). */
565
+ offset(n) {
566
+ return new _Query(
567
+ this.source,
568
+ { ...this.plan, offset: n },
569
+ this.joinContext,
570
+ this.aggregateStrategy
571
+ );
572
+ }
573
+ /**
574
+ * Resolve a `ref()`-declared foreign key and attach the right-side
575
+ * record under `opts.as`. — eager, single-FK, intra-
576
+ * vault joins.
577
+ *
578
+ * ```ts
579
+ * const rows = invoices.query()
580
+ * .where('status', '==', 'open')
581
+ * .join('clientId', { as: 'client' })
582
+ * .toArray()
583
+ * // → [{ id, amount, client: { id, name, ... } }, ...]
584
+ * ```
585
+ *
586
+ * Preconditions:
587
+ * - The Query must have a `joinContext` (constructed via
588
+ * `Collection.query()`, not `new Query`).
589
+ * - `field` must have a matching `refs: { [field]: ref('<target>') }`
590
+ * declaration on the left collection.
591
+ * - The target collection must be reachable via the vault
592
+ * (either currently open or openable on demand).
593
+ *
594
+ * Strategy:
595
+ * - Nested-loop against `lookupById` when the target source
596
+ * provides it (the common path for Collection targets).
597
+ * - Hash join otherwise, or when `{ strategy: 'hash' }` is
598
+ * explicitly passed for test purposes.
599
+ *
600
+ * Ref-mode semantics on dangling refs (left record has a non-null
601
+ * FK value pointing at a right-side id that doesn't exist):
602
+ * - `strict` → throws `DanglingReferenceError` with the full
603
+ * field / target / refId context.
604
+ * - `warn` → attaches `null` and emits a one-shot warning per
605
+ * unique dangling pair.
606
+ * - `cascade` → attaches `null` silently. Cascade is a
607
+ * delete-time mode; dangling refs visible at read time are
608
+ * either mid-flight cascades or pre-existing orphans, not a
609
+ * DSL-level error.
610
+ *
611
+ * A left-side record whose FK field is `null` / `undefined` is NOT
612
+ * a dangling ref — it's "no reference at all", always allowed
613
+ * regardless of mode.
614
+ *
615
+ * The return type widens `T` with `Record<As, R | null>`. The `R`
616
+ * parameter is optional — supply it explicitly for type-checked
617
+ * access to the joined fields:
618
+ *
619
+ * ```ts
620
+ * invoices.query().join<'client', Client>('clientId', { as: 'client' })
621
+ * // ^^^^^^^^^^^^^^^^^^^ alias literal + right-side type
622
+ * ```
623
+ *
624
+ * Without the generic, the joined field is typed as `unknown`, which
625
+ * still works but requires a cast to access its properties.
626
+ *
627
+ * Joins stay intra-vault by construction — cross-vault
628
+ * correlation goes through `Noydb.queryAcross`, not
629
+ * `.join()`.
630
+ */
631
+ join(field, opts) {
632
+ if (!this.joinContext) {
633
+ throw new Error(
634
+ `Query.join() requires a join context. Use collection.query() to construct a join-capable Query instead of the Query constructor directly (the direct constructor is only used for tests with plain-object sources).`
635
+ );
636
+ }
637
+ const descriptor = this.joinContext.resolveRef(field);
638
+ const isDictJoinField = !descriptor && this.joinContext.resolveDictSource?.(field) != null;
639
+ if (!descriptor && !isDictJoinField) {
640
+ throw new Error(
641
+ `Query.join(): no ref() declared for field "${field}" on collection "${this.joinContext.leftCollection}". Add refs: { ${field}: ref('<target-collection>') } to the collection options, then retry. See the ref() docs for the full list of modes.`
642
+ );
643
+ }
644
+ const leg = descriptor ? {
645
+ field,
646
+ as: opts.as,
647
+ target: descriptor.target,
648
+ mode: descriptor.mode,
649
+ strategy: opts.strategy,
650
+ maxRows: opts.maxRows,
651
+ // constraint #1 — always 'all' in. Do not remove.
652
+ partitionScope: "all"
653
+ } : {
654
+ // Dict join leg
655
+ field,
656
+ as: opts.as,
657
+ target: field,
658
+ // dict name = field name for dictKey
659
+ mode: "strict",
660
+ strategy: opts.strategy,
661
+ maxRows: opts.maxRows,
662
+ partitionScope: "all",
663
+ isDictJoin: true
664
+ };
665
+ return new _Query(
666
+ this.source,
667
+ { ...this.plan, joins: [...this.plan.joins, leg] },
668
+ this.joinContext,
669
+ this.aggregateStrategy
670
+ );
671
+ }
672
+ /**
673
+ * Execute the plan and return the matching records. When the plan
674
+ * carries any join legs, they are applied after `where` / `orderBy`
675
+ * / `limit` / `offset` narrow the left set. See the `.join()` doc
676
+ * for the ordering rationale.
677
+ */
678
+ toArray() {
679
+ const base = executePlanWithSource(this.source, this.plan);
680
+ if (this.plan.joins.length === 0) return base;
681
+ if (!this.joinContext) {
682
+ throw new Error(
683
+ `Query.toArray(): plan carries ${this.plan.joins.length} join leg(s) but no JoinContext is attached. This usually means the Query was constructed via the raw Query constructor with a plan that had joins pre-populated. Use collection.query().join(...) instead.`
684
+ );
685
+ }
686
+ return applyJoins(base, this.plan.joins, this.joinContext);
687
+ }
688
+ /** Return the first matching record, or null. Joins are applied. */
689
+ first() {
690
+ const arr = this.limit(1).toArray();
691
+ return arr[0] ?? null;
692
+ }
693
+ /**
694
+ * Return the number of matching records (after where/filter,
695
+ * before limit). **Joins are NOT applied** — count() reports the
696
+ * left-side cardinality, because joins in are projection-only
697
+ * (they attach an aliased field; they never filter). Running joins
698
+ * here just to discard the aliases would be wasteful, and in strict
699
+ * mode it could throw `DanglingReferenceError` for a call whose
700
+ * intent is purely to count.
701
+ */
702
+ count() {
703
+ const { candidates, remainingClauses } = candidateRecords(this.source, this.plan.clauses);
704
+ if (remainingClauses.length === 0) return candidates.length;
705
+ return filterRecords(candidates, remainingClauses).length;
706
+ }
707
+ /**
708
+ * Reduce the matching records through a named set of reducers.
709
+ * the aggregation terminal.
710
+ *
711
+ * ```ts
712
+ * const { total, n, avgAmount } = invoices.query()
713
+ * .where('status', '==', 'open')
714
+ * .aggregate({
715
+ * total: sum('amount'),
716
+ * n: count(),
717
+ * avgAmount: avg('amount'),
718
+ * })
719
+ * .run()
720
+ * ```
721
+ *
722
+ * Returns an `Aggregation<R>` wrapper with two terminals:
723
+ * - `.run(): R` — synchronous one-shot reduction
724
+ * - `.live(): LiveAggregation<R>` — reactive primitive that
725
+ * re-runs the reduction whenever the source notifies of a
726
+ * change. Always call `live.stop()` when finished.
727
+ *
728
+ * The reducer spec is bound here once and reused by both
729
+ * terminals — this is why `.aggregate()` returns a wrapper instead
730
+ * of being a direct terminal. Consumers who only need the static
731
+ * value read `.run()`; consumers wiring a reactive UI read
732
+ * `.live()`.
733
+ *
734
+ * Joins are intentionally NOT applied to aggregations in —
735
+ * the same logic as `.count()`. Joins in are projection-only
736
+ * (they attach an aliased field and never filter), so running
737
+ * them just to throw the aliases away would be wasteful. If you
738
+ * need a reducer that reads a joined field, open an issue —
739
+ * aggregations-across-joins is explicitly out of scope for v1.
740
+ *
741
+ * Every reducer factory accepts an optional `{ seed }` parameter
742
+ * that is plumbed through the protocol but unused by the
743
+ * executor — that's constraint #2. When partition-aware
744
+ * aggregation lands, the seed will carry running state across
745
+ * partition boundaries without an API break.
746
+ */
747
+ aggregate(spec) {
748
+ const source = this.source;
749
+ const clauses = this.plan.clauses;
750
+ const executeRecords = () => {
751
+ const { candidates, remainingClauses } = candidateRecords(source, clauses);
752
+ return remainingClauses.length === 0 ? candidates : filterRecords(candidates, remainingClauses);
753
+ };
754
+ const upstreams = [];
755
+ if (source.subscribe) {
756
+ const subscribe = source.subscribe.bind(source);
757
+ upstreams.push({ subscribe: (cb) => subscribe(cb) });
758
+ }
759
+ return this.aggregateStrategy.aggregate(executeRecords, spec, upstreams);
760
+ }
761
+ /**
762
+ * Partition matching records into buckets keyed by a field, then
763
+ * terminate with `.aggregate(spec)` to compute per-bucket
764
+ * reducers..
765
+ *
766
+ * ```ts
767
+ * const byClient = invoices.query()
768
+ * .where('status', '==', 'open')
769
+ * .groupBy('clientId')
770
+ * .aggregate({ total: sum('amount'), n: count() })
771
+ * .run()
772
+ * // → [ { clientId: 'c1', total: 5250, n: 3 }, … ]
773
+ * ```
774
+ *
775
+ * Result rows carry the group key value under the grouping field
776
+ * name plus every reducer output from the spec. Buckets are
777
+ * emitted in first-seen order — consumers who want a specific
778
+ * ordering should `.sort()` downstream.
779
+ *
780
+ * **Cardinality caps:** a one-shot warning fires at 10_000
781
+ * distinct groups; `GroupCardinalityError` throws at 100_000.
782
+ * Grouping on a high-uniqueness field like `id` or `createdAt` is
783
+ * almost always a query mistake — the error message names the
784
+ * field and observed cardinality and suggests narrowing with
785
+ * `.where()` first.
786
+ *
787
+ * **Null / undefined keys:** records with a missing or explicitly
788
+ * `null` group field get their own buckets. `Map`-based
789
+ * partitioning distinguishes `undefined` from `null`, so the two
790
+ * cases do NOT merge. Consumers who want them merged should
791
+ * coalesce upstream with `.filter()`.
792
+ *
793
+ * **Joins are not applied** — same rationale as `.count()` and
794
+ * `.aggregate()`. Joined fields in are projection-only, so
795
+ * running a join inside a grouping pipeline would be wasteful and
796
+ * could trigger `DanglingReferenceError` in strict mode for a
797
+ * call whose intent is purely to bucket-and-reduce. Grouping by
798
+ * a joined field is explicitly out of scope for — file an
799
+ * issue if a real consumer needs it.
800
+ *
801
+ * **Filter clauses (`.filter(fn)`):** grouped queries still
802
+ * support filter clauses in the underlying plan — they run in
803
+ * the same candidate/filter pipeline that `.aggregate()` uses.
804
+ * The performance caveat is the same: filter clauses cost O(N)
805
+ * per record and can't be index-accelerated.
806
+ */
807
+ groupBy(field) {
808
+ const source = this.source;
809
+ const clauses = this.plan.clauses;
810
+ const executeRecords = () => {
811
+ const { candidates, remainingClauses } = candidateRecords(source, clauses);
812
+ return remainingClauses.length === 0 ? candidates : filterRecords(candidates, remainingClauses);
813
+ };
814
+ const upstreams = [];
815
+ if (source.subscribe) {
816
+ const subscribe = source.subscribe.bind(source);
817
+ upstreams.push({ subscribe: (cb) => subscribe(cb) });
818
+ }
819
+ const joinCtx = this.joinContext;
820
+ const dictLabelResolver = joinCtx?.resolveDictSource ? (() => {
821
+ const dictSource = joinCtx.resolveDictSource(field);
822
+ if (!dictSource) return void 0;
823
+ const snapshot = dictSource.snapshot();
824
+ const dictMap = /* @__PURE__ */ new Map();
825
+ for (const entry of snapshot) {
826
+ const k = entry["key"];
827
+ const labels = entry["labels"];
828
+ if (typeof k === "string" && labels && typeof labels === "object") {
829
+ dictMap.set(k, labels);
830
+ }
831
+ }
832
+ return async (key, locale, fallback) => {
833
+ const labels = dictMap.get(key);
834
+ if (!labels) return void 0;
835
+ if (labels[locale] !== void 0) return labels[locale];
836
+ const chain = Array.isArray(fallback) ? fallback : fallback ? [fallback] : [];
837
+ for (const fb of chain) {
838
+ if (fb === "any") {
839
+ const any = Object.values(labels)[0];
840
+ if (any !== void 0) return any;
841
+ } else if (labels[fb] !== void 0) {
842
+ return labels[fb];
843
+ }
844
+ }
845
+ return void 0;
846
+ };
847
+ })() : void 0;
848
+ return this.aggregateStrategy.groupBy(executeRecords, field, upstreams, dictLabelResolver);
849
+ }
850
+ /**
851
+ * Re-run the query whenever the source notifies of changes.
852
+ * Returns an unsubscribe function. The callback receives the latest result.
853
+ * Throws if the source does not support subscriptions.
854
+ *
855
+ * **For joined queries, prefer `.live()`** — `subscribe()`
856
+ * only re-fires on LEFT-side changes, so joined data can be
857
+ * stale if the right side mutates between emissions. `.live()`
858
+ * merges change streams from every join target.
859
+ */
860
+ subscribe(cb) {
861
+ if (!this.source.subscribe) {
862
+ throw new Error("Query source does not support subscriptions. Pass a source with a subscribe() method.");
863
+ }
864
+ cb(this.toArray());
865
+ return this.source.subscribe(() => cb(this.toArray()));
866
+ }
867
+ /**
868
+ * Reactive terminal — returns a `LiveQuery<T>` that re-runs the
869
+ * query and updates its `value` whenever any source feeding it
870
+ * mutates..
871
+ *
872
+ * For non-joined queries, `.live()` is a convenience over the
873
+ * existing `.subscribe()` callback shape: a hand-rolled reactive
874
+ * primitive with `value` / `error` fields and a `subscribe(cb)`
875
+ * notification channel. Frame-agnostic — Vue / React / Solid
876
+ * adapters wrap it in their own primitive.
877
+ *
878
+ * For joined queries, `.live()` additionally subscribes to every
879
+ * join target's change stream. Mutations on a right-side
880
+ * collection (insert / update / delete of a client referenced by
881
+ * an invoice) re-fire the live query and re-evaluate every
882
+ * dependent left row. Right-side targets are deduped by
883
+ * collection name, so a chain that joins the same target twice
884
+ * (e.g. billing client + shipping client → both 'clients') only
885
+ * subscribes once.
886
+ *
887
+ * **Ref-mode behavior on right-side disappearance** — matches the
888
+ * eager `.toArray()` contract from :
889
+ * - `strict` → re-run throws `DanglingReferenceError`. The
890
+ * LiveQuery catches the throw, stores it in `live.error`, and
891
+ * notifies listeners (the throw does NOT propagate out of
892
+ * the source's change handler — that would tear down the
893
+ * emitter). Consumers check `live.error` after each
894
+ * notification and render an error state in the UI.
895
+ * - `warn` → joined value flips to `null`; the existing
896
+ * warn-channel deduplication keeps repeated re-runs from
897
+ * spamming the console.
898
+ * - `cascade` → no special handling needed; the cascade-
899
+ * delete mechanism propagates the right-side delete into the
900
+ * left collection on the next tick, and the live query
901
+ * naturally re-fires with the orphaned left rows gone.
902
+ *
903
+ * Always call `live.stop()` when finished — it tears down every
904
+ * upstream subscription. The Vue layer's `onUnmounted` hook
905
+ * should call `stop()` automatically; raw consumers must do it
906
+ * themselves.
907
+ *
908
+ * **Limitations:**
909
+ * - No granular delta updates — the whole query re-runs on
910
+ * every change.
911
+ * - No microtask batching — bursty changes produce one re-run
912
+ * per change.
913
+ * - No re-planning under live mutations — the planner picks
914
+ * once at subscription time and reuses the same plan.
915
+ * - Streaming live joins are deferred.
916
+ */
917
+ live() {
918
+ const upstreams = [];
919
+ if (this.source.subscribe) {
920
+ const leftSubscribe = this.source.subscribe.bind(this.source);
921
+ upstreams.push({
922
+ subscribe: (cb) => leftSubscribe(cb)
923
+ });
924
+ }
925
+ if (this.plan.joins.length > 0 && this.joinContext) {
926
+ const subscribed = /* @__PURE__ */ new Set();
927
+ for (const leg of this.plan.joins) {
928
+ if (subscribed.has(leg.target)) continue;
929
+ subscribed.add(leg.target);
930
+ const rightSource = this.joinContext.resolveSource(leg.target);
931
+ if (rightSource?.subscribe) {
932
+ const rightSubscribe = rightSource.subscribe.bind(rightSource);
933
+ upstreams.push({
934
+ subscribe: (cb) => rightSubscribe(cb)
935
+ });
936
+ }
937
+ }
938
+ }
939
+ return buildLiveQuery(() => this.toArray(), upstreams);
940
+ }
941
+ /**
942
+ * Return the plan as a JSON-friendly object. FilterClause entries are
943
+ * stripped (their `fn` cannot be serialized) and replaced with
944
+ * { type: 'filter', fn: '[function]' } so devtools can still see them.
945
+ */
946
+ toPlan() {
947
+ return serializePlan(this.plan);
948
+ }
949
+ };
950
+ function executePlanWithSource(source, plan) {
951
+ const { candidates, remainingClauses } = candidateRecords(source, plan.clauses);
952
+ let result = remainingClauses.length === 0 ? [...candidates] : filterRecords(candidates, remainingClauses);
953
+ if (plan.orderBy.length > 0) {
954
+ result = sortRecords(result, plan.orderBy);
955
+ }
956
+ if (plan.offset > 0) {
957
+ result = result.slice(plan.offset);
958
+ }
959
+ if (plan.limit !== void 0) {
960
+ result = result.slice(0, plan.limit);
961
+ }
962
+ return result;
963
+ }
964
+ function candidateRecords(source, clauses) {
965
+ const indexes = source.getIndexes?.();
966
+ if (!indexes || !source.lookupById || clauses.length === 0) {
967
+ return { candidates: source.snapshot(), remainingClauses: clauses };
968
+ }
969
+ const lookupById = (id) => source.lookupById?.(id);
970
+ for (let i = 0; i < clauses.length; i++) {
971
+ const clause = clauses[i];
972
+ if (clause.type !== "field") continue;
973
+ if (!indexes.has(clause.field)) continue;
974
+ let ids = null;
975
+ if (clause.op === "==") {
976
+ ids = indexes.lookupEqual(clause.field, clause.value);
977
+ } else if (clause.op === "in" && Array.isArray(clause.value)) {
978
+ ids = indexes.lookupIn(clause.field, clause.value);
979
+ }
980
+ if (ids !== null) {
981
+ const remaining = [];
982
+ for (let j = 0; j < clauses.length; j++) {
983
+ if (j !== i) remaining.push(clauses[j]);
984
+ }
985
+ return {
986
+ candidates: materializeIds(ids, lookupById),
987
+ remainingClauses: remaining
988
+ };
989
+ }
990
+ }
991
+ return { candidates: source.snapshot(), remainingClauses: clauses };
992
+ }
993
+ function materializeIds(ids, lookupById) {
994
+ const out = [];
995
+ for (const id of ids) {
996
+ const record = lookupById(id);
997
+ if (record !== void 0) out.push(record);
998
+ }
999
+ return out;
1000
+ }
1001
+ function executePlan(records, plan) {
1002
+ let result = filterRecords(records, plan.clauses);
1003
+ if (plan.orderBy.length > 0) {
1004
+ result = sortRecords(result, plan.orderBy);
1005
+ }
1006
+ if (plan.offset > 0) {
1007
+ result = result.slice(plan.offset);
1008
+ }
1009
+ if (plan.limit !== void 0) {
1010
+ result = result.slice(0, plan.limit);
1011
+ }
1012
+ return result;
1013
+ }
1014
+ function filterRecords(records, clauses) {
1015
+ if (clauses.length === 0) return [...records];
1016
+ const out = [];
1017
+ for (const r of records) {
1018
+ let matches = true;
1019
+ for (const clause of clauses) {
1020
+ if (!evaluateClause(r, clause)) {
1021
+ matches = false;
1022
+ break;
1023
+ }
1024
+ }
1025
+ if (matches) out.push(r);
1026
+ }
1027
+ return out;
1028
+ }
1029
+ function sortRecords(records, orderBy) {
1030
+ return [...records].sort((a, b) => {
1031
+ for (const { field, direction } of orderBy) {
1032
+ const av = readField(a, field);
1033
+ const bv = readField(b, field);
1034
+ const cmp = compareValues(av, bv);
1035
+ if (cmp !== 0) return direction === "asc" ? cmp : -cmp;
1036
+ }
1037
+ return 0;
1038
+ });
1039
+ }
1040
+ function readField(record, field) {
1041
+ if (record === null || record === void 0) return void 0;
1042
+ if (!field.includes(".")) {
1043
+ return record[field];
1044
+ }
1045
+ const segments = field.split(".");
1046
+ let cursor = record;
1047
+ for (const segment of segments) {
1048
+ if (cursor === null || cursor === void 0) return void 0;
1049
+ cursor = cursor[segment];
1050
+ }
1051
+ return cursor;
1052
+ }
1053
+ function compareValues(a, b) {
1054
+ if (a === void 0 || a === null) return b === void 0 || b === null ? 0 : 1;
1055
+ if (b === void 0 || b === null) return -1;
1056
+ if (typeof a === "number" && typeof b === "number") return a - b;
1057
+ if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0;
1058
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
1059
+ return 0;
1060
+ }
1061
+ function serializePlan(plan) {
1062
+ return {
1063
+ clauses: plan.clauses.map(serializeClause),
1064
+ orderBy: plan.orderBy,
1065
+ limit: plan.limit,
1066
+ offset: plan.offset,
1067
+ joins: plan.joins
1068
+ };
1069
+ }
1070
+ function serializeClause(clause) {
1071
+ if (clause.type === "filter") {
1072
+ return { type: "filter", fn: "[function]" };
1073
+ }
1074
+ if (clause.type === "group") {
1075
+ return {
1076
+ type: "group",
1077
+ op: clause.op,
1078
+ clauses: clause.clauses.map(serializeClause)
1079
+ };
1080
+ }
1081
+ return clause;
1082
+ }
1083
+
1084
+ // src/indexing/eager-indexes.ts
1085
+ var CollectionIndexes = class {
1086
+ indexes = /* @__PURE__ */ new Map();
1087
+ /**
1088
+ * Declare an index. Subsequent record additions are tracked under it.
1089
+ * Calling this twice for the same field is a no-op (idempotent).
1090
+ */
1091
+ declare(field) {
1092
+ if (this.indexes.has(field)) return;
1093
+ this.indexes.set(field, { field, buckets: /* @__PURE__ */ new Map() });
1094
+ }
1095
+ /** True if the given field has a declared index. */
1096
+ has(field) {
1097
+ return this.indexes.has(field);
1098
+ }
1099
+ /** All declared field names, in declaration order. */
1100
+ fields() {
1101
+ return [...this.indexes.keys()];
1102
+ }
1103
+ /**
1104
+ * Build all declared indexes from a snapshot of records.
1105
+ * Called once per hydration. O(N × indexes.size).
1106
+ */
1107
+ build(records) {
1108
+ for (const idx of this.indexes.values()) {
1109
+ idx.buckets.clear();
1110
+ for (const { id, record } of records) {
1111
+ addToIndex(idx, id, record);
1112
+ }
1113
+ }
1114
+ }
1115
+ /**
1116
+ * Insert or update a single record across all indexes.
1117
+ * Called by `Collection.put()` after the encrypted write succeeds.
1118
+ *
1119
+ * If `previousRecord` is provided, the record is removed from any old
1120
+ * buckets first — this is the update path. Pass `null` for fresh adds.
1121
+ */
1122
+ upsert(id, newRecord, previousRecord) {
1123
+ if (this.indexes.size === 0) return;
1124
+ if (previousRecord !== null) {
1125
+ this.remove(id, previousRecord);
1126
+ }
1127
+ for (const idx of this.indexes.values()) {
1128
+ addToIndex(idx, id, newRecord);
1129
+ }
1130
+ }
1131
+ /**
1132
+ * Remove a record from all indexes. Called by `Collection.delete()`
1133
+ * (and as the first half of `upsert` for the update path).
1134
+ */
1135
+ remove(id, record) {
1136
+ if (this.indexes.size === 0) return;
1137
+ for (const idx of this.indexes.values()) {
1138
+ removeFromIndex(idx, id, record);
1139
+ }
1140
+ }
1141
+ /** Drop all index data. Called when the collection is invalidated. */
1142
+ clear() {
1143
+ for (const idx of this.indexes.values()) {
1144
+ idx.buckets.clear();
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Equality lookup: return the set of record ids whose `field` matches
1149
+ * the given value. Returns `null` if no index covers the field — the
1150
+ * caller should fall back to a linear scan.
1151
+ *
1152
+ * The returned Set is a reference to the index's internal storage —
1153
+ * callers must NOT mutate it.
1154
+ */
1155
+ lookupEqual(field, value) {
1156
+ const idx = this.indexes.get(field);
1157
+ if (!idx) return null;
1158
+ const key = stringifyKey(value);
1159
+ return idx.buckets.get(key) ?? EMPTY_SET;
1160
+ }
1161
+ /**
1162
+ * Set lookup: return the union of record ids whose `field` matches any
1163
+ * of the given values. Returns `null` if no index covers the field.
1164
+ */
1165
+ lookupIn(field, values) {
1166
+ const idx = this.indexes.get(field);
1167
+ if (!idx) return null;
1168
+ const out = /* @__PURE__ */ new Set();
1169
+ for (const value of values) {
1170
+ const key = stringifyKey(value);
1171
+ const bucket = idx.buckets.get(key);
1172
+ if (bucket) {
1173
+ for (const id of bucket) out.add(id);
1174
+ }
1175
+ }
1176
+ return out;
1177
+ }
1178
+ };
1179
+ var EMPTY_SET = /* @__PURE__ */ new Set();
1180
+ function stringifyKey(value) {
1181
+ if (value === null || value === void 0) return "\0NULL\0";
1182
+ if (typeof value === "string") return value;
1183
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1184
+ if (value instanceof Date) return value.toISOString();
1185
+ return "\0OBJECT\0";
1186
+ }
1187
+ function addToIndex(idx, id, record) {
1188
+ const value = readPath(record, idx.field);
1189
+ if (value === null || value === void 0) return;
1190
+ const key = stringifyKey(value);
1191
+ let bucket = idx.buckets.get(key);
1192
+ if (!bucket) {
1193
+ bucket = /* @__PURE__ */ new Set();
1194
+ idx.buckets.set(key, bucket);
1195
+ }
1196
+ bucket.add(id);
1197
+ }
1198
+ function removeFromIndex(idx, id, record) {
1199
+ const value = readPath(record, idx.field);
1200
+ if (value === null || value === void 0) return;
1201
+ const key = stringifyKey(value);
1202
+ const bucket = idx.buckets.get(key);
1203
+ if (!bucket) return;
1204
+ bucket.delete(id);
1205
+ if (bucket.size === 0) idx.buckets.delete(key);
1206
+ }
1207
+
1208
+ // src/aggregate/reducers.ts
1209
+ function count(opts) {
1210
+ const _seed = opts?.seed;
1211
+ void _seed;
1212
+ return {
1213
+ init: () => 0,
1214
+ step: (state) => state + 1,
1215
+ remove: (state) => state - 1,
1216
+ finalize: (state) => state
1217
+ };
1218
+ }
1219
+ function sum(field, opts) {
1220
+ const _seed = opts?.seed;
1221
+ void _seed;
1222
+ return {
1223
+ init: () => 0,
1224
+ step: (state, record) => state + readNumber(record, field),
1225
+ remove: (state, record) => state - readNumber(record, field),
1226
+ finalize: (state) => state
1227
+ };
1228
+ }
1229
+ function avg(field, opts) {
1230
+ const _seed = opts?.seed;
1231
+ void _seed;
1232
+ return {
1233
+ init: () => ({ sum: 0, count: 0 }),
1234
+ step: (state, record) => ({
1235
+ sum: state.sum + readNumber(record, field),
1236
+ count: state.count + 1
1237
+ }),
1238
+ remove: (state, record) => ({
1239
+ sum: state.sum - readNumber(record, field),
1240
+ count: state.count - 1
1241
+ }),
1242
+ finalize: (state) => state.count === 0 ? null : state.sum / state.count
1243
+ };
1244
+ }
1245
+ function pushValue(state, value) {
1246
+ return { values: [...state.values, value] };
1247
+ }
1248
+ function removeValue(state, value) {
1249
+ const idx = state.values.indexOf(value);
1250
+ if (idx < 0) return state;
1251
+ const next = state.values.slice();
1252
+ next.splice(idx, 1);
1253
+ return { values: next };
1254
+ }
1255
+ function min(field, opts) {
1256
+ const _seed = opts?.seed;
1257
+ void _seed;
1258
+ return {
1259
+ init: () => ({ values: [] }),
1260
+ step: (state, record) => pushValue(state, readNumber(record, field)),
1261
+ remove: (state, record) => removeValue(state, readNumber(record, field)),
1262
+ finalize: (state) => {
1263
+ if (state.values.length === 0) return null;
1264
+ let out = state.values[0];
1265
+ for (let i = 1; i < state.values.length; i++) {
1266
+ const v = state.values[i];
1267
+ if (v < out) out = v;
1268
+ }
1269
+ return out;
1270
+ }
1271
+ };
1272
+ }
1273
+ function max(field, opts) {
1274
+ const _seed = opts?.seed;
1275
+ void _seed;
1276
+ return {
1277
+ init: () => ({ values: [] }),
1278
+ step: (state, record) => pushValue(state, readNumber(record, field)),
1279
+ remove: (state, record) => removeValue(state, readNumber(record, field)),
1280
+ finalize: (state) => {
1281
+ if (state.values.length === 0) return null;
1282
+ let out = state.values[0];
1283
+ for (let i = 1; i < state.values.length; i++) {
1284
+ const v = state.values[i];
1285
+ if (v > out) out = v;
1286
+ }
1287
+ return out;
1288
+ }
1289
+ };
1290
+ }
1291
+ function readNumber(record, field) {
1292
+ const value = readPath(record, field);
1293
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1294
+ }
1295
+
1296
+ // src/aggregate/aggregation.ts
1297
+ function reduceRecords(records, spec) {
1298
+ const state = {};
1299
+ for (const key of Object.keys(spec)) {
1300
+ state[key] = spec[key].init();
1301
+ }
1302
+ for (const record of records) {
1303
+ for (const key of Object.keys(spec)) {
1304
+ state[key] = spec[key].step(state[key], record);
1305
+ }
1306
+ }
1307
+ const result = {};
1308
+ for (const key of Object.keys(spec)) {
1309
+ result[key] = spec[key].finalize(state[key]);
1310
+ }
1311
+ return result;
1312
+ }
1313
+ var LiveAggregationImpl = class {
1314
+ constructor(recompute, upstreams) {
1315
+ this.recompute = recompute;
1316
+ try {
1317
+ this.value = recompute();
1318
+ this.error = void 0;
1319
+ } catch (err) {
1320
+ this.value = void 0;
1321
+ this.error = err;
1322
+ }
1323
+ for (const upstream of upstreams) {
1324
+ const unsub = upstream.subscribe(() => this.refresh());
1325
+ this.unsubscribes.push(unsub);
1326
+ }
1327
+ }
1328
+ recompute;
1329
+ value;
1330
+ error;
1331
+ listeners = /* @__PURE__ */ new Set();
1332
+ unsubscribes = [];
1333
+ stopped = false;
1334
+ refresh() {
1335
+ if (this.stopped) return;
1336
+ try {
1337
+ this.value = this.recompute();
1338
+ this.error = void 0;
1339
+ } catch (err) {
1340
+ this.error = err;
1341
+ }
1342
+ for (const listener of this.listeners) {
1343
+ try {
1344
+ listener();
1345
+ } catch (err) {
1346
+ console.warn("[noy-db] LiveAggregation listener threw:", err);
1347
+ }
1348
+ }
1349
+ }
1350
+ subscribe(cb) {
1351
+ if (this.stopped) {
1352
+ return () => {
1353
+ };
1354
+ }
1355
+ this.listeners.add(cb);
1356
+ return () => {
1357
+ this.listeners.delete(cb);
1358
+ };
1359
+ }
1360
+ stop() {
1361
+ if (this.stopped) return;
1362
+ this.stopped = true;
1363
+ for (const unsub of this.unsubscribes) {
1364
+ try {
1365
+ unsub();
1366
+ } catch (err) {
1367
+ console.warn("[noy-db] LiveAggregation upstream unsubscribe threw:", err);
1368
+ }
1369
+ }
1370
+ this.unsubscribes.length = 0;
1371
+ this.listeners.clear();
1372
+ }
1373
+ };
1374
+ var Aggregation = class {
1375
+ constructor(executeRecords, spec, upstreams) {
1376
+ this.executeRecords = executeRecords;
1377
+ this.spec = spec;
1378
+ this.upstreams = upstreams;
1379
+ }
1380
+ executeRecords;
1381
+ spec;
1382
+ upstreams;
1383
+ /**
1384
+ * Execute the query and reduce the results synchronously.
1385
+ * Returns the reduced shape matching the spec — e.g. a spec of
1386
+ * `{ total: sum('amount'), n: count() }` returns
1387
+ * `{ total: number, n: number }`.
1388
+ */
1389
+ run() {
1390
+ return reduceRecords(this.executeRecords(), this.spec);
1391
+ }
1392
+ /**
1393
+ * Build a reactive `LiveAggregation<R>` that re-runs the reduction
1394
+ * whenever any upstream source notifies of a change. The initial
1395
+ * value is computed eagerly in the constructor, so consumers can
1396
+ * read `live.value` immediately after calling `.live()`.
1397
+ *
1398
+ * Always call `live.stop()` when finished — it tears down the
1399
+ * upstream subscriptions. Vue's `onUnmounted` is the canonical
1400
+ * place.
1401
+ *
1402
+ * **Implementation note:** every upstream change triggers a full
1403
+ * re-reduction. Incremental maintenance (O(1) per delta for
1404
+ * sum/count/avg via the reducer protocol's `remove()` method) is a
1405
+ * planned follow-up optimization — the protocol already supports
1406
+ * it, but the executor doesn't drive it yet. Consumers get
1407
+ * correct, reactive values today; future PRs can switch to
1408
+ * delta-based maintenance without changing this API.
1409
+ */
1410
+ live() {
1411
+ const recompute = () => reduceRecords(this.executeRecords(), this.spec);
1412
+ return new LiveAggregationImpl(recompute, this.upstreams);
1413
+ }
1414
+ };
1415
+ function buildLiveAggregation(recompute, upstreams) {
1416
+ return new LiveAggregationImpl(recompute, upstreams);
1417
+ }
1418
+
1419
+ // src/aggregate/groupby.ts
1420
+ var GROUPBY_WARN_CARDINALITY = 1e4;
1421
+ var GROUPBY_MAX_CARDINALITY = 1e5;
1422
+ var warnedCardinalityFields = /* @__PURE__ */ new Set();
1423
+ function warnCardinalityApproaching(field, observed) {
1424
+ if (warnedCardinalityFields.has(field)) return;
1425
+ warnedCardinalityFields.add(field);
1426
+ console.warn(
1427
+ `[noy-db] .groupBy("${field}") produced ${observed} distinct groups, ${Math.round(observed / GROUPBY_MAX_CARDINALITY * 100)}% of the ${GROUPBY_MAX_CARDINALITY}-group ceiling. Narrow the query with .where() before grouping, or switch to a lower-cardinality field.`
1428
+ );
1429
+ }
1430
+ function resetGroupByWarnings() {
1431
+ warnedCardinalityFields.clear();
1432
+ }
1433
+ var GroupedQuery = class {
1434
+ constructor(executeRecords, field, upstreams, dictLabelResolver) {
1435
+ this.executeRecords = executeRecords;
1436
+ this.field = field;
1437
+ this.upstreams = upstreams;
1438
+ this.dictLabelResolver = dictLabelResolver;
1439
+ }
1440
+ executeRecords;
1441
+ field;
1442
+ upstreams;
1443
+ dictLabelResolver;
1444
+ /**
1445
+ * Build a grouped aggregation. Returns a `GroupedAggregation`
1446
+ * with `.run()`, `.runAsync()`, and `.live()` terminals — same shape
1447
+ * as the non-grouped `.aggregate()` wrapper, just with an array
1448
+ * result (one row per bucket) instead of a single reduced object.
1449
+ */
1450
+ aggregate(spec) {
1451
+ return new GroupedAggregation(
1452
+ this.executeRecords,
1453
+ this.field,
1454
+ spec,
1455
+ this.upstreams,
1456
+ this.dictLabelResolver
1457
+ );
1458
+ }
1459
+ };
1460
+ function groupAndReduce(records, field, spec) {
1461
+ const buckets = /* @__PURE__ */ new Map();
1462
+ for (const record of records) {
1463
+ const key = readPath(record, field);
1464
+ let bucket = buckets.get(key);
1465
+ if (bucket === void 0) {
1466
+ if (buckets.size >= GROUPBY_MAX_CARDINALITY) {
1467
+ throw new GroupCardinalityError(
1468
+ field,
1469
+ buckets.size + 1,
1470
+ GROUPBY_MAX_CARDINALITY
1471
+ );
1472
+ }
1473
+ bucket = [];
1474
+ buckets.set(key, bucket);
1475
+ }
1476
+ bucket.push(record);
1477
+ }
1478
+ if (buckets.size >= GROUPBY_WARN_CARDINALITY) {
1479
+ warnCardinalityApproaching(field, buckets.size);
1480
+ }
1481
+ const keys = Object.keys(spec);
1482
+ const out = [];
1483
+ for (const [groupKey, bucketRecords] of buckets) {
1484
+ const state = {};
1485
+ for (const key of keys) {
1486
+ state[key] = spec[key].init();
1487
+ }
1488
+ for (const record of bucketRecords) {
1489
+ for (const key of keys) {
1490
+ state[key] = spec[key].step(state[key], record);
1491
+ }
1492
+ }
1493
+ const row = { [field]: groupKey };
1494
+ for (const key of keys) {
1495
+ row[key] = spec[key].finalize(state[key]);
1496
+ }
1497
+ out.push(row);
1498
+ }
1499
+ return out;
1500
+ }
1501
+ var GroupedAggregation = class {
1502
+ constructor(executeRecords, field, spec, upstreams, dictLabelResolver) {
1503
+ this.executeRecords = executeRecords;
1504
+ this.field = field;
1505
+ this.spec = spec;
1506
+ this.upstreams = upstreams;
1507
+ this.dictLabelResolver = dictLabelResolver;
1508
+ }
1509
+ executeRecords;
1510
+ field;
1511
+ spec;
1512
+ upstreams;
1513
+ dictLabelResolver;
1514
+ /** Execute the query, group, reduce, and return an array of rows. */
1515
+ run() {
1516
+ return groupAndReduce(this.executeRecords(), this.field, this.spec);
1517
+ }
1518
+ /**
1519
+ * Execute the query, group, reduce, and resolve `<field>Label` for
1520
+ * each result row when the grouping field is a `dictKey` and a
1521
+ * `locale` is provided. Returns `R[]` synchronously when
1522
+ * no locale is specified (identical to `.run()`).
1523
+ *
1524
+ * The `<field>Label` field is appended to each row. Rows whose group
1525
+ * key has no dictionary entry get `<field>Label: undefined`.
1526
+ */
1527
+ async runAsync(opts) {
1528
+ const rows = groupAndReduce(this.executeRecords(), this.field, this.spec);
1529
+ if (!opts?.locale || !this.dictLabelResolver) return rows;
1530
+ const resolve = this.dictLabelResolver;
1531
+ const locale = opts.locale;
1532
+ const fallback = opts.fallback;
1533
+ const labelKey = `${this.field}Label`;
1534
+ return Promise.all(
1535
+ rows.map(async (row) => {
1536
+ const key = row[this.field];
1537
+ if (typeof key !== "string") return row;
1538
+ const label = await resolve(key, locale, fallback);
1539
+ return { ...row, [labelKey]: label };
1540
+ })
1541
+ );
1542
+ }
1543
+ /**
1544
+ * Build a reactive `LiveAggregation<R[]>` that re-runs the full
1545
+ * group-and-reduce pipeline whenever any upstream source notifies
1546
+ * of a change. Same error-isolation and idempotent-stop contract
1547
+ * as `Aggregation.live()` — the implementation delegates to the
1548
+ * same `LiveAggregationImpl` class by threading a fresh
1549
+ * recompute closure through the existing constructor.
1550
+ *
1551
+ * uses naive full re-run on every change. Incremental
1552
+ * per-bucket maintenance (apply `step` on inserted records,
1553
+ * `remove` on deleted records, route by bucket key) is a future
1554
+ * optimization — the reducer protocol admits it, but wiring
1555
+ * delta-aware source subscriptions is a separate PR.
1556
+ *
1557
+ * Always call `live.stop()` when finished.
1558
+ */
1559
+ live() {
1560
+ const recompute = () => groupAndReduce(this.executeRecords(), this.field, this.spec);
1561
+ return buildLiveAggregation(recompute, this.upstreams);
1562
+ }
1563
+ };
1564
+
1565
+ // src/query/scan-builder.ts
1566
+ var DEFAULT_SCAN_PAGE_SIZE = 100;
1567
+ var ScanBuilder = class _ScanBuilder {
1568
+ pageProvider;
1569
+ pageSize;
1570
+ clauses;
1571
+ /**
1572
+ * Zero-or-more join legs to apply per record as the stream flows.
1573
+ * Each leg attaches the resolved right-side record (or null) under
1574
+ * its alias. — streaming joins.
1575
+ *
1576
+ * Joins are evaluated AFTER clauses, so a `where()` filtered-out
1577
+ * record never triggers a right-side lookup. This is the same
1578
+ * ordering as `Query.toArray()` (clauses first, joins after) and
1579
+ * keeps the streaming path from doing wasted work.
1580
+ */
1581
+ joins;
1582
+ /**
1583
+ * Join resolution context. Required for `.join()` to translate a
1584
+ * field name into a target collection + ref mode and to resolve
1585
+ * the right-side `JoinableSource`. Optional because tests
1586
+ * construct ScanBuilder directly with synthetic page providers
1587
+ * that don't know about ref() — calling `.join()` without a
1588
+ * context throws with an actionable error.
1589
+ */
1590
+ joinContext;
1591
+ constructor(pageProvider, pageSize = DEFAULT_SCAN_PAGE_SIZE, clauses = [], joins = [], joinContext) {
1592
+ this.pageProvider = pageProvider;
1593
+ this.pageSize = pageSize;
1594
+ this.clauses = clauses;
1595
+ this.joins = joins;
1596
+ this.joinContext = joinContext;
1597
+ }
1598
+ /**
1599
+ * Add a field comparison. Runs per record as the scan stream
1600
+ * flows through, so non-matching records are dropped before they
1601
+ * reach `.aggregate()` or the iteration consumer. Multiple
1602
+ * `.where()` calls are AND-combined — same semantics as
1603
+ * `Query.where()`.
1604
+ *
1605
+ * Clauses cannot use the secondary-index fast path here because
1606
+ * the scan sources records from the adapter's paginator, not from
1607
+ * the in-memory cache where indexes live. Index-accelerated scans
1608
+ * are a future optimization — the current implementation
1609
+ * evaluates clauses per record in O(1) per clause.
1610
+ */
1611
+ where(field, op, value) {
1612
+ const clause = { type: "field", field, op, value };
1613
+ return new _ScanBuilder(
1614
+ this.pageProvider,
1615
+ this.pageSize,
1616
+ [...this.clauses, clause],
1617
+ this.joins,
1618
+ this.joinContext
1619
+ );
1620
+ }
1621
+ /**
1622
+ * Escape hatch: add an arbitrary predicate function. Same
1623
+ * non-serializable caveat as `Query.filter()` — filter clauses
1624
+ * don't round-trip through `toPlan()`. Prefer `.where()` when
1625
+ * possible.
1626
+ */
1627
+ filter(fn) {
1628
+ const clause = {
1629
+ type: "filter",
1630
+ fn
1631
+ };
1632
+ return new _ScanBuilder(
1633
+ this.pageProvider,
1634
+ this.pageSize,
1635
+ [...this.clauses, clause],
1636
+ this.joins,
1637
+ this.joinContext
1638
+ );
1639
+ }
1640
+ /**
1641
+ * Resolve a `ref()`-declared foreign key per record as the scan
1642
+ * stream flows, attaching the right-side record (or null) under
1643
+ * `opts.as`. — streaming joins over `scan()`.
1644
+ *
1645
+ * ```ts
1646
+ * for await (const inv of invoices.scan().join('clientId', { as: 'client' })) {
1647
+ * await processInvoice(inv) // inv.client is attached
1648
+ * }
1649
+ *
1650
+ * // Or terminate with .aggregate() for streaming joined aggregation
1651
+ * const { total } = await invoices.scan()
1652
+ * .where('status', '==', 'open')
1653
+ * .join('clientId', { as: 'client' })
1654
+ * .aggregate({ total: sum('amount') })
1655
+ * ```
1656
+ *
1657
+ * **The key difference from eager `.join()`:** the LEFT
1658
+ * side streams page-by-page from the adapter and is never
1659
+ * materialized. Memory ceiling on the left is O(pageSize), not
1660
+ * O(rowCount). This is what makes streaming joins suitable for
1661
+ * collections that exceed the eager join's 50_000-row ceiling.
1662
+ *
1663
+ * **Right-side strategy** is auto-selected per leg:
1664
+ * - **Indexed** — right source exposes `lookupById`, so each
1665
+ * left row costs O(1). This is the common path for
1666
+ * Collection right sides, which back `lookupById` with a Map
1667
+ * lookup over the in-memory cache. The right collection must
1668
+ * be in eager mode (the same constraint as eager join's
1669
+ * `querySourceForJoin` from ).
1670
+ * - **Hash** — right source has only `snapshot()`. Build a
1671
+ * `Map<id, record>` once at iteration start, probe per left
1672
+ * row. Same correctness, same per-row cost as the indexed
1673
+ * path; the difference is the upfront cost of materializing
1674
+ * the right side once.
1675
+ *
1676
+ * Both strategies hold the right side in memory for the duration
1677
+ * of the iteration. The "streaming" property applies to the LEFT
1678
+ * side only — true left-and-right streaming joins (where neither
1679
+ * side fits in memory) require a sort-merge join planner that's
1680
+ * out of scope for.
1681
+ *
1682
+ * **Ref-mode semantics** match eager `.join()` exactly:
1683
+ * - `strict` → throws `DanglingReferenceError` mid-stream
1684
+ * when a left record points at a non-existent right id.
1685
+ * The throw aborts the async iterator — consumers should
1686
+ * wrap the `for await` in try/catch if they want to recover.
1687
+ * - `warn` → attaches `null` and emits a one-shot warning
1688
+ * per unique dangling pair (deduped via the same warn
1689
+ * channel as eager join).
1690
+ * - `cascade` → attaches `null` silently. A delete-time mode;
1691
+ * dangling refs at read time are mid-flight or pre-existing
1692
+ * orphans, not a DSL error.
1693
+ *
1694
+ * Left records with null/undefined FK values attach `null`
1695
+ * regardless of mode — same "no reference at all" policy as
1696
+ * eager join and write-time `enforceRefsOnPut`.
1697
+ *
1698
+ * **Multi-FK chaining** is supported via repeated `.join()`
1699
+ * calls: each leg resolves an independent ref. Each leg
1700
+ * independently picks its right-side strategy and applies its
1701
+ * own ref mode.
1702
+ *
1703
+ * **Joins are NOT applied** to a `.aggregate()` terminal that
1704
+ * doesn't reference joined fields — wait, that's not quite
1705
+ * right. The streaming path actually DOES apply joins before
1706
+ * `.aggregate()` because the join attaches a field that the
1707
+ * spec might reference. Unlike `Query.aggregate()` (which skips
1708
+ * joins entirely as a projection-only short-circuit), the
1709
+ * streaming aggregation can't know whether the spec touches a
1710
+ * joined field, so it always applies joins. Consumers who want
1711
+ * unjoined streaming aggregation should leave `.join()` off the
1712
+ * chain — the chain is composable for a reason.
1713
+ *
1714
+ * constraint #1 — every JoinLeg carries `partitionScope:
1715
+ * 'all'` plumbed through but never read by. Same seam as
1716
+ * eager join.
1717
+ */
1718
+ join(field, opts) {
1719
+ if (!this.joinContext) {
1720
+ throw new Error(
1721
+ `ScanBuilder.join() requires a join context. Use collection.scan() to construct a join-capable scan instead of the ScanBuilder constructor directly (the direct constructor is only used for tests with synthetic page providers).`
1722
+ );
1723
+ }
1724
+ const descriptor = this.joinContext.resolveRef(field);
1725
+ if (!descriptor) {
1726
+ throw new Error(
1727
+ `ScanBuilder.join(): no ref() declared for field "${field}" on collection "${this.joinContext.leftCollection}". Add refs: { ${field}: ref('<target-collection>') } to the collection options, then retry.`
1728
+ );
1729
+ }
1730
+ const leg = {
1731
+ field,
1732
+ as: opts.as,
1733
+ target: descriptor.target,
1734
+ mode: descriptor.mode,
1735
+ strategy: void 0,
1736
+ maxRows: void 0,
1737
+ // constraint #1 — always 'all' in, never read by
1738
+ // the streaming executor. partition-aware scan joins
1739
+ // will populate this from where() predicates without
1740
+ // changing the planner shape.
1741
+ partitionScope: "all"
1742
+ };
1743
+ return new _ScanBuilder(
1744
+ this.pageProvider,
1745
+ this.pageSize,
1746
+ this.clauses,
1747
+ [...this.joins, leg],
1748
+ this.joinContext
1749
+ );
1750
+ }
1751
+ /**
1752
+ * Iterate the scan as an async iterable. Walks the page
1753
+ * provider's cursors forward until exhaustion, applying every
1754
+ * clause per record — only matching records are yielded.
1755
+ *
1756
+ * Backward-compatible with the previous async-generator `scan()`
1757
+ * return type for `for await … of` consumers.
1758
+ */
1759
+ async *[Symbol.asyncIterator]() {
1760
+ const joinResolvers = this.joins.length === 0 ? null : this.buildJoinResolvers();
1761
+ let page = await this.pageProvider.listPage({ limit: this.pageSize });
1762
+ while (true) {
1763
+ for (const record of page.items) {
1764
+ if (!this.recordMatches(record)) continue;
1765
+ if (joinResolvers === null) {
1766
+ yield record;
1767
+ } else {
1768
+ let attached = record;
1769
+ for (const resolver of joinResolvers) {
1770
+ attached = this.applyOneJoinStreaming(attached, resolver);
1771
+ }
1772
+ yield attached;
1773
+ }
1774
+ }
1775
+ if (page.nextCursor === null) return;
1776
+ page = await this.pageProvider.listPage({
1777
+ cursor: page.nextCursor,
1778
+ limit: this.pageSize
1779
+ });
1780
+ }
1781
+ }
1782
+ /**
1783
+ * Per-leg right-side resolution state. Built once at iteration
1784
+ * start and reused for every left record. Two strategies:
1785
+ *
1786
+ * - `lookupById`: present when the right source exposes the
1787
+ * hook directly (typical Collection right side). Per-row
1788
+ * cost is O(1).
1789
+ * - `hashByPrimaryKey`: built from `snapshot()` when no
1790
+ * lookupById. Per-row cost is O(1) after the upfront O(N)
1791
+ * materialization. Same as eager join's hash strategy.
1792
+ *
1793
+ * `warnedKeys` is the per-leg dedup set for ref-mode 'warn'. We
1794
+ * key on `field→target:refId` so the same dangling pair only
1795
+ * warns once per iteration. The dedup is per-iteration, not
1796
+ * per-process — a long-running scan that re-iterates would warn
1797
+ * again, which is the desired behavior (the data may have
1798
+ * changed between iterations).
1799
+ */
1800
+ buildJoinResolvers() {
1801
+ if (!this.joinContext) {
1802
+ throw new Error(
1803
+ `ScanBuilder iterator: ${this.joins.length} join leg(s) present but no JoinContext attached. Use collection.scan() to construct a join-capable scan.`
1804
+ );
1805
+ }
1806
+ const resolvers = [];
1807
+ for (const leg of this.joins) {
1808
+ const source = this.joinContext.resolveSource(leg.target);
1809
+ if (!source) {
1810
+ throw new Error(
1811
+ `ScanBuilder.join() cannot resolve target collection "${leg.target}" (referenced from field "${leg.field}" on "${this.joinContext.leftCollection}"). Make sure the target collection has been opened via vault.collection() at least once before iterating the scan.`
1812
+ );
1813
+ }
1814
+ let lookupById = null;
1815
+ let hashByPrimaryKey = null;
1816
+ if (source.lookupById) {
1817
+ const fn = source.lookupById.bind(source);
1818
+ lookupById = (id) => fn(id);
1819
+ } else {
1820
+ const map = /* @__PURE__ */ new Map();
1821
+ for (const record of source.snapshot()) {
1822
+ const rawId = readPath(record, "id");
1823
+ const key = coerceRefKey2(rawId);
1824
+ if (key !== null) map.set(key, record);
1825
+ }
1826
+ hashByPrimaryKey = map;
1827
+ }
1828
+ resolvers.push({
1829
+ leg,
1830
+ source,
1831
+ lookupById,
1832
+ hashByPrimaryKey,
1833
+ warnedKeys: /* @__PURE__ */ new Set()
1834
+ });
1835
+ }
1836
+ return resolvers;
1837
+ }
1838
+ /**
1839
+ * Resolve a single join leg for one left record and return the
1840
+ * left record with the joined field attached under
1841
+ * `leg.as`. Pure function over `(left, resolver)`; never
1842
+ * mutates the input.
1843
+ *
1844
+ * Ref-mode dispatch matches eager `applyJoins` from :
1845
+ * - null/undefined FK → attach null silently (always allowed)
1846
+ * - dangling FK + strict → throw `DanglingReferenceError`
1847
+ * - dangling FK + warn → attach null, warn-once per pair
1848
+ * - dangling FK + cascade → attach null silently
1849
+ */
1850
+ applyOneJoinStreaming(left, resolver) {
1851
+ if (left === null || typeof left !== "object") {
1852
+ return left;
1853
+ }
1854
+ const { leg } = resolver;
1855
+ const rawId = readPath(left, leg.field);
1856
+ const refKey = coerceRefKey2(rawId);
1857
+ let right = void 0;
1858
+ if (refKey !== null) {
1859
+ if (resolver.lookupById !== null) {
1860
+ right = resolver.lookupById(refKey);
1861
+ } else if (resolver.hashByPrimaryKey !== null) {
1862
+ right = resolver.hashByPrimaryKey.get(refKey);
1863
+ }
1864
+ }
1865
+ const merged = {
1866
+ ...left
1867
+ };
1868
+ if (right === void 0) {
1869
+ if (refKey !== null && leg.mode === "strict") {
1870
+ throw new DanglingReferenceError({
1871
+ field: leg.field,
1872
+ target: leg.target,
1873
+ refId: refKey,
1874
+ message: `ScanBuilder.join() strict dangling: record references "${leg.target}:${refKey}" via field "${leg.field}", but no such record exists. Use ref() mode 'warn' or 'cascade' if dangling refs are acceptable, or run vault.checkIntegrity() to find and fix the orphans.`
1875
+ });
1876
+ }
1877
+ if (refKey !== null && leg.mode === "warn") {
1878
+ const dedupKey = `${leg.field}\u2192${leg.target}:${refKey}`;
1879
+ if (!resolver.warnedKeys.has(dedupKey)) {
1880
+ resolver.warnedKeys.add(dedupKey);
1881
+ console.warn(
1882
+ `[noy-db] ScanBuilder.join() encountered dangling ref in 'warn' mode: field "${leg.field}" \u2192 "${leg.target}:${refKey}" not found. Attaching null.`
1883
+ );
1884
+ }
1885
+ }
1886
+ merged[leg.as] = null;
1887
+ } else {
1888
+ merged[leg.as] = right;
1889
+ }
1890
+ return merged;
1891
+ }
1892
+ /**
1893
+ * Reduce the scan stream through a named set of reducers and
1894
+ * return the final aggregated shape.
1895
+ *
1896
+ * Memory is O(reducers): one mutable state slot per spec key.
1897
+ * Records flow through the pipeline one at a time via
1898
+ * `for await` and are discarded after their `step()` is applied
1899
+ * — never collected into an array. This is the distinguishing
1900
+ * property from `Query.aggregate()`, which materializes the full
1901
+ * match set first.
1902
+ *
1903
+ * Reuses the same reducer protocol as `Query.aggregate()`,
1904
+ * so `count()`, `sum(field)`, `avg(field)`, `min(field)`,
1905
+ * `max(field)` all work unchanged. The `{ seed }` parameter
1906
+ * plumbing from constraint #2 is honored transparently — the
1907
+ * factories ignore it in and the scan executor never
1908
+ * touches the per-reducer state construction.
1909
+ *
1910
+ * **Returns a Promise**, unlike `Query.aggregate().run()` which
1911
+ * is synchronous. The scan is inherently async because it walks
1912
+ * adapter pages, so the terminal has to be too. Consumers
1913
+ * destructure with await:
1914
+ *
1915
+ * ```ts
1916
+ * const { total, n } = await invoices.scan()
1917
+ * .where('year', '==', 2025)
1918
+ * .aggregate({ total: sum('amount'), n: count() })
1919
+ * ```
1920
+ *
1921
+ * **No `.live()` in.** `scan().aggregate().live()` would
1922
+ * require reconciling an unbounded streaming iteration with a
1923
+ * change-stream subscription — a design problem, not just a code
1924
+ * one. Consumers with huge collections and live needs should
1925
+ * narrow with `.where()` enough to fit in the 50k `query()`
1926
+ * limit and use `query().aggregate().live()` instead.
1927
+ */
1928
+ async aggregate(spec) {
1929
+ const keys = Object.keys(spec);
1930
+ const state = {};
1931
+ for (const key of keys) {
1932
+ state[key] = spec[key].init();
1933
+ }
1934
+ for await (const record of this) {
1935
+ for (const key of keys) {
1936
+ state[key] = spec[key].step(state[key], record);
1937
+ }
1938
+ }
1939
+ const result = {};
1940
+ for (const key of keys) {
1941
+ result[key] = spec[key].finalize(state[key]);
1942
+ }
1943
+ return result;
1944
+ }
1945
+ /**
1946
+ * Evaluate the clause list against a single record. Linear in
1947
+ * the clause count; short-circuits on first false. Clauses on a
1948
+ * scan are always re-evaluated per record — no index-accelerated
1949
+ * path, because the stream sources records from the adapter
1950
+ * paginator, not from the in-memory cache where indexes live.
1951
+ */
1952
+ recordMatches(record) {
1953
+ if (this.clauses.length === 0) return true;
1954
+ for (const clause of this.clauses) {
1955
+ if (!evaluateClause(record, clause)) return false;
1956
+ }
1957
+ return true;
1958
+ }
1959
+ };
1960
+ function coerceRefKey2(value) {
1961
+ if (value === null || value === void 0) return null;
1962
+ if (typeof value === "string") return value;
1963
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
1964
+ return null;
1965
+ }
1966
+ // Annotate the CommonJS export names for ESM import in node:
1967
+ 0 && (module.exports = {
1968
+ Aggregation,
1969
+ CollectionIndexes,
1970
+ DEFAULT_JOIN_MAX_ROWS,
1971
+ DanglingReferenceError,
1972
+ GROUPBY_MAX_CARDINALITY,
1973
+ GROUPBY_WARN_CARDINALITY,
1974
+ GroupCardinalityError,
1975
+ GroupedAggregation,
1976
+ GroupedQuery,
1977
+ IndexRequiredError,
1978
+ IndexWriteFailureError,
1979
+ JoinTooLargeError,
1980
+ Query,
1981
+ ScanBuilder,
1982
+ applyJoins,
1983
+ avg,
1984
+ buildLiveAggregation,
1985
+ buildLiveQuery,
1986
+ count,
1987
+ evaluateClause,
1988
+ evaluateFieldClause,
1989
+ executePlan,
1990
+ groupAndReduce,
1991
+ max,
1992
+ min,
1993
+ readPath,
1994
+ reduceRecords,
1995
+ resetGroupByWarnings,
1996
+ resetJoinWarnings,
1997
+ sum
1998
+ });
1999
+ //# sourceMappingURL=index.cjs.map