@noy-db/hub 0.2.0-pre.30 → 0.2.0-pre.31

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 (143) hide show
  1. package/dist/adapter/index.d.ts +9 -0
  2. package/dist/adapter/index.js +14 -0
  3. package/dist/attestation/index.d.ts +1 -1
  4. package/dist/attestation/index.js +3 -3
  5. package/dist/blobs/index.d.ts +3 -3
  6. package/dist/bundle/index.d.ts +3 -3
  7. package/dist/bundle/index.js +1 -1
  8. package/dist/{chunk-Y6YUWZTS.js → chunk-L5HHBOMS.js} +2 -2
  9. package/dist/{chunk-BVKLJBJJ.js → chunk-W5NVNJDX.js} +2 -1
  10. package/dist/{chunk-BVKLJBJJ.js.map → chunk-W5NVNJDX.js.map} +1 -1
  11. package/dist/consent/index.d.ts +2 -2
  12. package/dist/{decrypt-partition-Mhjh6nnG.d.ts → decrypt-partition-CXS5KopB.d.ts} +1 -1
  13. package/dist/derivations/index.d.ts +3 -3
  14. package/dist/{dev-unlock-DJ3IhgY9.d.ts → dev-unlock-CnHTTVea.d.ts} +1 -1
  15. package/dist/guards/index.d.ts +3 -3
  16. package/dist/{hash-DLwkYf1d.d.ts → hash-oc5paYl0.d.ts} +1 -1
  17. package/dist/history/index.d.ts +3 -3
  18. package/dist/i18n/index.d.ts +2 -2
  19. package/dist/{index-CMRIx_zx.d.ts → index-BmhGztUi.d.ts} +1 -1
  20. package/dist/index.d.ts +11 -11
  21. package/dist/index.js +2 -2
  22. package/dist/kernel/index.d.ts +2 -2
  23. package/dist/materialized-views/index.d.ts +3 -3
  24. package/dist/{mime-magic-CacDBwYp.d.ts → mime-magic-JeLKHRng.d.ts} +1 -1
  25. package/dist/{noydb-5MIBD77B.js → noydb-NAU2DTFP.js} +2 -2
  26. package/dist/noydb-NAU2DTFP.js.map +1 -0
  27. package/dist/overlay-views/index.d.ts +3 -3
  28. package/dist/periods/index.d.ts +2 -2
  29. package/dist/session/index.d.ts +3 -3
  30. package/dist/shadow/index.d.ts +2 -2
  31. package/dist/snapshots/index.d.ts +2 -2
  32. package/dist/store/index.d.ts +2 -2
  33. package/dist/sync/index.d.ts +1 -1
  34. package/dist/team/index.d.ts +2 -2
  35. package/dist/{transition-guard-J04-jime.d.ts → transition-guard-Dy3NioQr.d.ts} +1 -1
  36. package/dist/tx/index.d.ts +2 -2
  37. package/dist/{with-materialized-view-DNfzC5lH.d.ts → with-materialized-view-DIVeez0W.d.ts} +1 -1
  38. package/dist/{with-overlayed-view-qk3lbhOd.d.ts → with-overlayed-view-DVZrc5Hb.d.ts} +1 -1
  39. package/dist/{with-rollup-Dd3_ZG4b.d.ts → with-rollup-CbU-O4wP.d.ts} +1 -1
  40. package/package.json +62 -221
  41. package/dist/aggregate/index.cjs +0 -1144
  42. package/dist/aggregate/index.cjs.map +0 -1
  43. package/dist/aggregate/index.d.cts +0 -39
  44. package/dist/attestation/index.cjs +0 -305
  45. package/dist/attestation/index.cjs.map +0 -1
  46. package/dist/attestation/index.d.cts +0 -54
  47. package/dist/blobs/index.cjs +0 -1967
  48. package/dist/blobs/index.cjs.map +0 -1
  49. package/dist/blobs/index.d.cts +0 -48
  50. package/dist/bundle/index.cjs +0 -41045
  51. package/dist/bundle/index.cjs.map +0 -1
  52. package/dist/bundle/index.d.cts +0 -187
  53. package/dist/consent/index.cjs +0 -204
  54. package/dist/consent/index.cjs.map +0 -1
  55. package/dist/consent/index.d.cts +0 -27
  56. package/dist/crdt/index.cjs +0 -152
  57. package/dist/crdt/index.cjs.map +0 -1
  58. package/dist/crdt/index.d.cts +0 -30
  59. package/dist/decrypt-partition-C0iDpbSd.d.cts +0 -558
  60. package/dist/derivations/index.cjs +0 -469
  61. package/dist/derivations/index.cjs.map +0 -1
  62. package/dist/derivations/index.d.cts +0 -74
  63. package/dist/dev-unlock-0Z6yxfS7.d.cts +0 -263
  64. package/dist/discriminant-BN9REW3o.d.cts +0 -60
  65. package/dist/errors-BAWO5Z5a.d.cts +0 -1500
  66. package/dist/forget/index.cjs +0 -43
  67. package/dist/forget/index.cjs.map +0 -1
  68. package/dist/forget/index.d.cts +0 -1
  69. package/dist/guards/index.cjs +0 -455
  70. package/dist/guards/index.cjs.map +0 -1
  71. package/dist/guards/index.d.cts +0 -39
  72. package/dist/hash-Db8ncXGr.d.cts +0 -63
  73. package/dist/history/index.cjs +0 -1245
  74. package/dist/history/index.cjs.map +0 -1
  75. package/dist/history/index.d.cts +0 -65
  76. package/dist/i18n/index.cjs +0 -1280
  77. package/dist/i18n/index.cjs.map +0 -1
  78. package/dist/i18n/index.d.cts +0 -41
  79. package/dist/index-BMmajblo.d.cts +0 -362
  80. package/dist/index-C1SC1EPe.d.cts +0 -1325
  81. package/dist/index-xJEPkvMb.d.cts +0 -93
  82. package/dist/index.cjs +0 -48100
  83. package/dist/index.cjs.map +0 -1
  84. package/dist/index.d.cts +0 -1050
  85. package/dist/indexing/index.cjs +0 -803
  86. package/dist/indexing/index.cjs.map +0 -1
  87. package/dist/indexing/index.d.cts +0 -36
  88. package/dist/kernel/index.cjs +0 -751
  89. package/dist/kernel/index.cjs.map +0 -1
  90. package/dist/kernel/index.d.cts +0 -11
  91. package/dist/lazy-builder-eYZzLEL1.d.cts +0 -304
  92. package/dist/materialized-views/index.cjs +0 -1518
  93. package/dist/materialized-views/index.cjs.map +0 -1
  94. package/dist/materialized-views/index.d.cts +0 -187
  95. package/dist/mime-magic-sdMzsfVK.d.cts +0 -103
  96. package/dist/overlay-views/index.cjs +0 -399
  97. package/dist/overlay-views/index.cjs.map +0 -1
  98. package/dist/overlay-views/index.d.cts +0 -102
  99. package/dist/periods/index.cjs +0 -1041
  100. package/dist/periods/index.cjs.map +0 -1
  101. package/dist/periods/index.d.cts +0 -24
  102. package/dist/predicate-BmhBSPCH.d.cts +0 -267
  103. package/dist/query/index.cjs +0 -3480
  104. package/dist/query/index.cjs.map +0 -1
  105. package/dist/query/index.d.cts +0 -4
  106. package/dist/sealed-record/index.cjs +0 -139
  107. package/dist/sealed-record/index.cjs.map +0 -1
  108. package/dist/sealed-record/index.d.cts +0 -123
  109. package/dist/session/index.cjs +0 -495
  110. package/dist/session/index.cjs.map +0 -1
  111. package/dist/session/index.d.cts +0 -48
  112. package/dist/shadow/index.cjs +0 -133
  113. package/dist/shadow/index.cjs.map +0 -1
  114. package/dist/shadow/index.d.cts +0 -19
  115. package/dist/snapshots/index.cjs +0 -937
  116. package/dist/snapshots/index.cjs.map +0 -1
  117. package/dist/snapshots/index.d.cts +0 -30
  118. package/dist/store/index.cjs +0 -1091
  119. package/dist/store/index.cjs.map +0 -1
  120. package/dist/store/index.d.cts +0 -501
  121. package/dist/strategy-BSxFXGzb.d.cts +0 -110
  122. package/dist/strategy-mJExw4LQ.d.cts +0 -1069
  123. package/dist/sync/index.cjs +0 -1062
  124. package/dist/sync/index.cjs.map +0 -1
  125. package/dist/sync/index.d.cts +0 -45
  126. package/dist/team/index.cjs +0 -2805
  127. package/dist/team/index.cjs.map +0 -1
  128. package/dist/team/index.d.cts +0 -120
  129. package/dist/transition-guard-tWZIsaXe.d.cts +0 -165
  130. package/dist/tx/index.cjs +0 -614
  131. package/dist/tx/index.cjs.map +0 -1
  132. package/dist/tx/index.d.cts +0 -39
  133. package/dist/types-CdVfiQgt.d.cts +0 -15275
  134. package/dist/ulid-DRH25k3y.d.cts +0 -66
  135. package/dist/util/index.cjs +0 -237
  136. package/dist/util/index.cjs.map +0 -1
  137. package/dist/util/index.d.cts +0 -79
  138. package/dist/with-materialized-view-DfgPcvi9.d.cts +0 -27
  139. package/dist/with-overlayed-view-Cvfkx1lg.d.cts +0 -13
  140. package/dist/with-rollup-nIxo7h9z.d.cts +0 -47
  141. /package/dist/{noydb-5MIBD77B.js.map → adapter/index.js.map} +0 -0
  142. /package/dist/{chunk-Y6YUWZTS.js.map → chunk-L5HHBOMS.js.map} +0 -0
  143. /package/dist/{types-DHn9lEP0.d.ts → index-DHn9lEP0.d.ts} +0 -0
@@ -1,3480 +0,0 @@
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
- CrossJoinSourceUnknownError: () => CrossJoinSourceUnknownError,
26
- CrossJoinTooLargeError: () => CrossJoinTooLargeError,
27
- DEFAULT_CROSS_JOIN_MAX_ROWS: () => DEFAULT_CROSS_JOIN_MAX_ROWS,
28
- DEFAULT_JOIN_MAX_ROWS: () => DEFAULT_JOIN_MAX_ROWS,
29
- DanglingReferenceError: () => DanglingReferenceError,
30
- GROUPBY_MAX_CARDINALITY: () => GROUPBY_MAX_CARDINALITY,
31
- GROUPBY_WARN_CARDINALITY: () => GROUPBY_WARN_CARDINALITY,
32
- GroupCardinalityError: () => GroupCardinalityError,
33
- GroupedAggregation: () => GroupedAggregation,
34
- GroupedQuery: () => GroupedQuery,
35
- GroupedQueryN: () => GroupedQueryN,
36
- IndexRequiredError: () => IndexRequiredError,
37
- IndexWriteFailureError: () => IndexWriteFailureError,
38
- JoinTooLargeError: () => JoinTooLargeError,
39
- Query: () => Query,
40
- ScanBuilder: () => ScanBuilder,
41
- applyJoins: () => applyJoins,
42
- avg: () => avg,
43
- buildLiveAggregation: () => buildLiveAggregation,
44
- buildLiveQuery: () => buildLiveQuery,
45
- count: () => count,
46
- evaluateClause: () => evaluateClause,
47
- evaluateFieldClause: () => evaluateFieldClause,
48
- executePlan: () => executePlan,
49
- groupAndReduce: () => groupAndReduce,
50
- max: () => max,
51
- min: () => min,
52
- readPath: () => readPath,
53
- reduceRecords: () => reduceRecords,
54
- resetGroupByWarnings: () => resetGroupByWarnings,
55
- resetJoinWarnings: () => resetJoinWarnings,
56
- sum: () => sum
57
- });
58
- module.exports = __toCommonJS(query_exports);
59
-
60
- // src/money/fixed-point.ts
61
- function expandExponent(s) {
62
- const m = /^([+-]?)(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(s);
63
- if (!m) return s;
64
- const sign = m[1] === "-" ? "-" : "";
65
- const intp = m[2];
66
- const frac = m[3] ?? "";
67
- const exp = Number(m[4]);
68
- const digits = intp + frac;
69
- const pointPos = intp.length + exp;
70
- let body;
71
- if (pointPos <= 0) {
72
- body = "0." + "0".repeat(-pointPos) + digits;
73
- } else if (pointPos >= digits.length) {
74
- body = digits + "0".repeat(pointPos - digits.length);
75
- } else {
76
- body = digits.slice(0, pointPos) + "." + digits.slice(pointPos);
77
- }
78
- return sign + body;
79
- }
80
- function toCanonicalDecimalString(input) {
81
- let s;
82
- if (typeof input === "number") {
83
- if (!Number.isFinite(input)) return null;
84
- s = String(input);
85
- } else {
86
- s = input.trim();
87
- }
88
- s = expandExponent(s);
89
- if (s.startsWith("+")) s = s.slice(1);
90
- if (!/^-?(\d+(\.\d*)?|\.\d+)$/.test(s)) return null;
91
- return s;
92
- }
93
- function shouldRoundUp(negative, lastKeptDigit, firstDiscarded, hasMoreNonZeroAfterFirst, mode) {
94
- switch (mode) {
95
- case "up":
96
- return true;
97
- case "down":
98
- return false;
99
- case "ceil":
100
- return !negative;
101
- case "floor":
102
- return negative;
103
- case "half-up":
104
- return firstDiscarded >= 5;
105
- case "half-down":
106
- return firstDiscarded > 5 || firstDiscarded === 5 && hasMoreNonZeroAfterFirst;
107
- case "half-even":
108
- if (firstDiscarded > 5) return true;
109
- if (firstDiscarded < 5) return false;
110
- return hasMoreNonZeroAfterFirst || lastKeptDigit % 2 === 1;
111
- }
112
- }
113
- function parseToScaledInt(input, scale, rounding) {
114
- const canonical = toCanonicalDecimalString(input);
115
- if (canonical === null) return { ok: false, reason: "nonfinite" };
116
- const negative = canonical.startsWith("-");
117
- const unsigned = negative ? canonical.slice(1) : canonical;
118
- const dot = unsigned.indexOf(".");
119
- const intPart = dot === -1 ? unsigned : unsigned.slice(0, dot);
120
- const fracPart = dot === -1 ? "" : unsigned.slice(dot + 1);
121
- const intDigits = intPart === "" ? "0" : intPart;
122
- if (fracPart.length <= scale) {
123
- const keep2 = fracPart.padEnd(scale, "0");
124
- const magnitude2 = BigInt(intDigits + keep2);
125
- return { ok: true, value: negative && magnitude2 !== 0n ? -magnitude2 : magnitude2 };
126
- }
127
- const keep = fracPart.slice(0, scale);
128
- const tail = fracPart.slice(scale);
129
- const magnitudeDigits = intDigits + keep;
130
- let magnitude = BigInt(magnitudeDigits);
131
- if (/^0+$/.test(tail)) {
132
- return { ok: true, value: negative && magnitude !== 0n ? -magnitude : magnitude };
133
- }
134
- if (rounding === void 0) return { ok: false, reason: "precision" };
135
- const lastKeptDigit = Number(magnitudeDigits[magnitudeDigits.length - 1]);
136
- const firstDiscarded = Number(tail[0]);
137
- const hasMoreNonZeroAfterFirst = /[1-9]/.test(tail.slice(1));
138
- if (shouldRoundUp(negative, lastKeptDigit, firstDiscarded, hasMoreNonZeroAfterFirst, rounding)) {
139
- magnitude += 1n;
140
- }
141
- return { ok: true, value: negative && magnitude !== 0n ? -magnitude : magnitude };
142
- }
143
- function formatScaledInt(value, scale) {
144
- const negative = value < 0n;
145
- const abs = (negative ? -value : value).toString();
146
- if (scale === 0) return (negative ? "-" : "") + abs;
147
- const padded = abs.padStart(scale + 1, "0");
148
- const cut = padded.length - scale;
149
- const intPart = padded.slice(0, cut);
150
- const fracPart = padded.slice(cut);
151
- return (negative ? "-" : "") + intPart + "." + fracPart;
152
- }
153
-
154
- // src/money/iso4217.ts
155
- var MINOR_UNITS = {
156
- // 2-decimal majors
157
- EUR: 2,
158
- USD: 2,
159
- GBP: 2,
160
- CHF: 2,
161
- CAD: 2,
162
- AUD: 2,
163
- NZD: 2,
164
- SGD: 2,
165
- HKD: 2,
166
- CNY: 2,
167
- INR: 2,
168
- BRL: 2,
169
- MXN: 2,
170
- ZAR: 2,
171
- RUB: 2,
172
- TRY: 2,
173
- PLN: 2,
174
- SEK: 2,
175
- NOK: 2,
176
- DKK: 2,
177
- CZK: 2,
178
- HUF: 2,
179
- RON: 2,
180
- ILS: 2,
181
- THB: 2,
182
- PHP: 2,
183
- MYR: 2,
184
- IDR: 2,
185
- AED: 2,
186
- SAR: 2,
187
- QAR: 2,
188
- EGP: 2,
189
- // 0-decimal
190
- JPY: 0,
191
- KRW: 0,
192
- ISK: 0,
193
- CLP: 0,
194
- VND: 0,
195
- XOF: 0,
196
- XAF: 0,
197
- PYG: 0,
198
- // 3-decimal
199
- BHD: 3,
200
- KWD: 3,
201
- OMR: 3,
202
- TND: 3,
203
- JOD: 3,
204
- IQD: 3,
205
- LYD: 3
206
- };
207
- function scaleForCurrency(code) {
208
- const v = MINOR_UNITS[code];
209
- return v === void 0 ? null : v;
210
- }
211
-
212
- // src/errors.ts
213
- var NoydbError = class extends Error {
214
- /** Machine-readable error code. Stable across library versions. */
215
- code;
216
- constructor(code, message) {
217
- super(message);
218
- this.name = "NoydbError";
219
- this.code = code;
220
- }
221
- };
222
- var ValidationError = class extends NoydbError {
223
- constructor(message = "Validation error") {
224
- super("VALIDATION_ERROR", message);
225
- this.name = "ValidationError";
226
- }
227
- };
228
- var GroupCardinalityError = class extends NoydbError {
229
- /** The field being grouped on. */
230
- field;
231
- /** Observed number of distinct groups at the moment the cap tripped. */
232
- cardinality;
233
- /** The cap that was exceeded. */
234
- maxGroups;
235
- constructor(field, cardinality, maxGroups) {
236
- super(
237
- "GROUP_CARDINALITY",
238
- `.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.`
239
- );
240
- this.name = "GroupCardinalityError";
241
- this.field = field;
242
- this.cardinality = cardinality;
243
- this.maxGroups = maxGroups;
244
- }
245
- };
246
- var IndexRequiredError = class extends NoydbError {
247
- collection;
248
- touchedFields;
249
- missingFields;
250
- constructor(args) {
251
- super(
252
- "INDEX_REQUIRED",
253
- `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.`
254
- );
255
- this.name = "IndexRequiredError";
256
- this.collection = args.collection;
257
- this.touchedFields = [...args.touchedFields];
258
- this.missingFields = [...args.missingFields];
259
- }
260
- };
261
- var IndexWriteFailureError = class extends NoydbError {
262
- recordId;
263
- field;
264
- op;
265
- cause;
266
- constructor(args) {
267
- super(
268
- "INDEX_WRITE_FAILURE",
269
- `Index side-car ${args.op} failed for field "${args.field}" on record "${args.recordId}"`
270
- );
271
- this.name = "IndexWriteFailureError";
272
- this.recordId = args.recordId;
273
- this.field = args.field;
274
- this.op = args.op;
275
- this.cause = args.cause;
276
- }
277
- };
278
- var LocaleNotSpecifiedError = class extends NoydbError {
279
- /** The field name that required a locale. */
280
- field;
281
- constructor(field, message) {
282
- super(
283
- "LOCALE_NOT_SPECIFIED",
284
- message ?? `Cannot read i18nText field "${field}" without a locale. Pass { locale } to get()/list()/query() or set a default via openVault(name, { locale }).`
285
- );
286
- this.name = "LocaleNotSpecifiedError";
287
- this.field = field;
288
- }
289
- };
290
- var JoinTooLargeError = class extends NoydbError {
291
- leftRows;
292
- rightRows;
293
- maxRows;
294
- side;
295
- constructor(opts) {
296
- super("JOIN_TOO_LARGE", opts.message);
297
- this.name = "JoinTooLargeError";
298
- this.leftRows = opts.leftRows;
299
- this.rightRows = opts.rightRows;
300
- this.maxRows = opts.maxRows;
301
- this.side = opts.side;
302
- }
303
- };
304
- var CrossJoinTooLargeError = class extends NoydbError {
305
- target;
306
- expected;
307
- limit;
308
- constructor(opts) {
309
- super(
310
- "CROSS_JOIN_TOO_LARGE",
311
- `crossJoin("${opts.target}"): would produce ${opts.expected} rows, exceeding the limit of ${opts.limit}. Narrow the left side with .where() first, or raise the ceiling with crossJoin("${opts.target}", { ..., maxRows: ${opts.expected} }).`
312
- );
313
- this.name = "CrossJoinTooLargeError";
314
- this.target = opts.target;
315
- this.expected = opts.expected;
316
- this.limit = opts.limit;
317
- }
318
- };
319
- var CrossJoinSourceUnknownError = class extends NoydbError {
320
- target;
321
- leftCollection;
322
- constructor(target, leftCollection) {
323
- super(
324
- "CROSS_JOIN_SOURCE_UNKNOWN",
325
- `crossJoin("${target}"): collection "${target}" is not known in the vault (cross-joining from "${leftCollection}"). Make sure "${target}" is open in the same vault before executing this query.`
326
- );
327
- this.name = "CrossJoinSourceUnknownError";
328
- this.target = target;
329
- this.leftCollection = leftCollection;
330
- }
331
- };
332
- var DanglingReferenceError = class extends NoydbError {
333
- field;
334
- target;
335
- refId;
336
- constructor(opts) {
337
- super("DANGLING_REFERENCE", opts.message);
338
- this.name = "DanglingReferenceError";
339
- this.field = opts.field;
340
- this.target = opts.target;
341
- this.refId = opts.refId;
342
- }
343
- };
344
-
345
- // src/money/descriptor.ts
346
- var MoneyUnsupportedError = class extends NoydbError {
347
- constructor(field, message) {
348
- super(
349
- "MONEY_UNSUPPORTED",
350
- message ?? `money: operation is not supported on field "${field}" \u2014 use sum() and count() and divide at the boundary`
351
- );
352
- this.field = field;
353
- this.name = "MoneyUnsupportedError";
354
- }
355
- field;
356
- };
357
-
358
- // src/money/where.ts
359
- function isMoneyValueObject(v) {
360
- return typeof v === "object" && v !== null && "currency" in v;
361
- }
362
- function parseOperand(field, raw, desc) {
363
- let amount;
364
- let currency;
365
- if (desc.mode === "fixed") {
366
- currency = desc.fixedCurrency;
367
- amount = raw;
368
- } else if (isMoneyValueObject(raw)) {
369
- currency = String(raw.currency);
370
- amount = raw.amount;
371
- } else {
372
- const sole = desc.soleCurrency();
373
- if (sole === void 0) {
374
- throw new MoneyUnsupportedError(
375
- `where("${field}"): field is multi-currency \u2014 compare against { amount, currency }, not a bare amount`
376
- );
377
- }
378
- currency = sole;
379
- amount = raw;
380
- }
381
- if (typeof amount !== "number" && typeof amount !== "string") {
382
- throw new MoneyUnsupportedError(
383
- `where("${field}"): operand ${JSON.stringify(raw)} is not a money amount`
384
- );
385
- }
386
- const r = parseToScaledInt(amount, desc.scaleFor(currency), desc.rounding);
387
- if (!r.ok) {
388
- throw new MoneyUnsupportedError(
389
- `where("${field}"): operand ${JSON.stringify(amount)} is not a finite decimal`
390
- );
391
- }
392
- return { scaled: r.value.toString(), currency };
393
- }
394
- function moneyFieldClause(field, op, value, desc) {
395
- switch (op) {
396
- case "==":
397
- case "!=":
398
- case "<":
399
- case "<=":
400
- case ">":
401
- case ">=": {
402
- const e = parseOperand(field, value, desc);
403
- return withMoney(field, op, value, desc, [e]);
404
- }
405
- case "between": {
406
- if (!Array.isArray(value) || value.length !== 2) {
407
- throw new MoneyUnsupportedError(`where("${field}"): 'between' needs a [lo, hi] tuple`);
408
- }
409
- const lo = parseOperand(field, value[0], desc);
410
- const hi = parseOperand(field, value[1], desc);
411
- if (lo.currency !== hi.currency) {
412
- throw new MoneyUnsupportedError(
413
- `where("${field}"): 'between' bounds mix currencies (${lo.currency} vs ${hi.currency})`
414
- );
415
- }
416
- return withMoney(field, op, value, desc, [lo, hi]);
417
- }
418
- case "in": {
419
- if (!Array.isArray(value)) {
420
- throw new MoneyUnsupportedError(`where("${field}"): 'in' needs an array of amounts`);
421
- }
422
- return withMoney(field, op, value, desc, value.map((v) => parseOperand(field, v, desc)));
423
- }
424
- default:
425
- throw new MoneyUnsupportedError(
426
- `where("${field}"): operator '${op}' is not supported on a money field`
427
- );
428
- }
429
- }
430
- function withMoney(field, op, originalValue, desc, entries) {
431
- const money = { mode: desc.mode, entries };
432
- const value = desc.mode !== "fixed" ? originalValue : entries.length === 1 && op !== "in" && op !== "between" ? entries[0].scaled : entries.map((e) => e.scaled);
433
- return { type: "field", field, op, value, money };
434
- }
435
- function readStored(actual, operand) {
436
- let amount;
437
- let currency;
438
- if (operand.mode === "fixed") {
439
- if (typeof actual !== "string" && typeof actual !== "number") return null;
440
- amount = actual;
441
- currency = operand.entries[0]?.currency ?? "";
442
- } else {
443
- if (!isMoneyValueObject(actual)) return null;
444
- if (typeof actual.currency !== "string") return null;
445
- amount = actual.amount;
446
- currency = actual.currency;
447
- }
448
- if (typeof amount !== "string" && typeof amount !== "number") return null;
449
- try {
450
- return { scaled: BigInt(amount).toString(), currency };
451
- } catch {
452
- return null;
453
- }
454
- }
455
- function evaluateMoneyClause(actual, op, operand) {
456
- const stored = readStored(actual, operand);
457
- if (stored === null) return op === "!=";
458
- const a = BigInt(stored.scaled);
459
- if (op === "in") {
460
- return operand.entries.some(
461
- (e2) => e2.currency === stored.currency && BigInt(e2.scaled) === a
462
- );
463
- }
464
- if (op === "between") {
465
- const [lo, hi] = operand.entries;
466
- if (!lo || !hi || lo.currency !== stored.currency) return false;
467
- return a >= BigInt(lo.scaled) && a <= BigInt(hi.scaled);
468
- }
469
- const e = operand.entries[0];
470
- if (!e) return false;
471
- if (e.currency !== stored.currency) return op === "!=";
472
- const b = BigInt(e.scaled);
473
- switch (op) {
474
- case "==":
475
- return a === b;
476
- case "!=":
477
- return a !== b;
478
- case "<":
479
- return a < b;
480
- case "<=":
481
- return a <= b;
482
- case ">":
483
- return a > b;
484
- case ">=":
485
- return a >= b;
486
- default:
487
- return false;
488
- }
489
- }
490
-
491
- // src/query/predicate.ts
492
- function readPath(record, path) {
493
- if (record === null || record === void 0) return void 0;
494
- if (!path.includes(".")) {
495
- return record[path];
496
- }
497
- const segments = path.split(".");
498
- let cursor = record;
499
- for (const segment of segments) {
500
- if (cursor === null || cursor === void 0) return void 0;
501
- cursor = cursor[segment];
502
- }
503
- return cursor;
504
- }
505
- function evaluateFieldClause(record, clause) {
506
- const actual = readPath(record, clause.field);
507
- const { op, value } = clause;
508
- if (clause.money) return evaluateMoneyClause(actual, op, clause.money);
509
- switch (op) {
510
- case "==":
511
- return actual === value;
512
- case "!=":
513
- return actual !== value;
514
- case "<":
515
- return isComparable(actual, value) && actual < value;
516
- case "<=":
517
- return isComparable(actual, value) && actual <= value;
518
- case ">":
519
- return isComparable(actual, value) && actual > value;
520
- case ">=":
521
- return isComparable(actual, value) && actual >= value;
522
- case "in":
523
- return Array.isArray(value) && value.includes(actual);
524
- case "contains":
525
- if (typeof actual === "string") return typeof value === "string" && actual.includes(value);
526
- if (Array.isArray(actual)) return actual.includes(value);
527
- return false;
528
- case "startsWith":
529
- return typeof actual === "string" && typeof value === "string" && actual.startsWith(value);
530
- case "between": {
531
- if (!Array.isArray(value) || value.length !== 2) return false;
532
- const [lo, hi] = value;
533
- if (!isComparable(actual, lo) || !isComparable(actual, hi)) return false;
534
- return actual >= lo && actual <= hi;
535
- }
536
- default: {
537
- const _exhaustive = op;
538
- void _exhaustive;
539
- return false;
540
- }
541
- }
542
- }
543
- function isComparable(a, b) {
544
- if (typeof a === "number" && typeof b === "number") return true;
545
- if (typeof a === "string" && typeof b === "string") return true;
546
- if (a instanceof Date && b instanceof Date) return true;
547
- return false;
548
- }
549
- function evaluateClause(record, clause, fnRecord) {
550
- switch (clause.type) {
551
- case "field":
552
- return evaluateFieldClause(record, clause);
553
- case "filter":
554
- return clause.fn(fnRecord !== void 0 ? fnRecord : record);
555
- case "wherePredicate":
556
- return clause.fn(fnRecord !== void 0 ? fnRecord : record, clause.ctx);
557
- case "crossJoin":
558
- throw new Error(
559
- `evaluateClause: 'crossJoin' clauses are expansion primitives and are not evaluated per-record. This is a query planner routing error \u2014 crossJoin clauses must be extracted from the clause list before calling evaluateClause or filterRecords.`
560
- );
561
- case "group":
562
- if (clause.op === "and") {
563
- for (const child of clause.clauses) {
564
- if (!evaluateClause(record, child, fnRecord)) return false;
565
- }
566
- return true;
567
- } else {
568
- for (const child of clause.clauses) {
569
- if (evaluateClause(record, child, fnRecord)) return true;
570
- }
571
- return false;
572
- }
573
- }
574
- }
575
- function hasFnClause(clauses) {
576
- for (const c of clauses) {
577
- if (c.type === "filter" || c.type === "wherePredicate") return true;
578
- if (c.type === "group" && hasFnClause(c.clauses)) return true;
579
- }
580
- return false;
581
- }
582
-
583
- // src/i18n/policy.ts
584
- function resolvePolicy(onMissing, layer) {
585
- const explicit = onMissing && typeof onMissing === "object" ? onMissing[layer] : void 0;
586
- const scalar = typeof onMissing === "string" ? onMissing : void 0;
587
- const layerDefault = layer === "guard" ? "substitute" : void 0;
588
- return explicit ?? layerDefault ?? scalar ?? "throw";
589
- }
590
-
591
- // src/i18n/script.ts
592
- var LATIN_BASE = /* @__PURE__ */ new Set([
593
- "en",
594
- "fr",
595
- "de",
596
- "es",
597
- "it",
598
- "pt",
599
- "nl",
600
- "sv",
601
- "no",
602
- "da",
603
- "fi",
604
- "is",
605
- "pl",
606
- "cs",
607
- "sk",
608
- "hu",
609
- "ro",
610
- "hr",
611
- "sl",
612
- "et",
613
- "lv",
614
- "lt",
615
- "tr",
616
- "vi",
617
- "id",
618
- "ms",
619
- "tl",
620
- "sw",
621
- "af",
622
- "ca",
623
- "gl",
624
- "eu",
625
- "cy",
626
- "ga"
627
- ]);
628
- var SCRIPT_TABLE = {
629
- th: ["Thai"],
630
- ko: ["Hangul", "Han"],
631
- ja: ["Han", "Hiragana", "Katakana"],
632
- zh: ["Han"],
633
- ar: ["Arabic"],
634
- fa: ["Arabic"],
635
- ur: ["Arabic"],
636
- ru: ["Cyrillic"],
637
- uk: ["Cyrillic"],
638
- bg: ["Cyrillic"],
639
- sr: ["Cyrillic"],
640
- he: ["Hebrew"],
641
- el: ["Greek"],
642
- hi: ["Devanagari"],
643
- ta: ["Tamil"],
644
- km: ["Khmer"],
645
- lo: ["Lao"],
646
- my: ["Myanmar"]
647
- };
648
- var SUBTAG_SCRIPTS = {
649
- Latn: ["Latin"],
650
- Cyrl: ["Cyrillic", "Latin"],
651
- Hans: ["Han", "Latin"],
652
- Hant: ["Han", "Latin"],
653
- Thai: ["Thai", "Latin"],
654
- Arab: ["Arabic", "Latin"]
655
- };
656
- function inferScripts(locale) {
657
- const parts = locale.split("-");
658
- const subtag = parts.find((t) => /^[A-Z][a-z]{3}$/.test(t));
659
- if (subtag && SUBTAG_SCRIPTS[subtag]) return SUBTAG_SCRIPTS[subtag];
660
- const base = (parts[0] ?? "").toLowerCase();
661
- if (LATIN_BASE.has(base)) return ["Latin"];
662
- const primary = SCRIPT_TABLE[base];
663
- if (primary) return [...primary, "Latin"];
664
- return ["Latin"];
665
- }
666
- var BASELINE = String.raw`\p{White_Space}\p{Script=Common}\p{Script=Inherited}\p{Mark}`;
667
-
668
- // src/i18n/core.ts
669
- function toChain(fallback) {
670
- return Array.isArray(fallback) ? fallback : fallback ? [fallback] : [];
671
- }
672
- function pickFromChain(value, chain) {
673
- for (const fb of chain) {
674
- if (fb === "any") {
675
- const any = Object.values(value).find((v) => v !== "");
676
- if (any !== void 0) return any;
677
- } else if (value[fb] !== void 0 && value[fb] !== "") {
678
- return value[fb];
679
- }
680
- }
681
- return void 0;
682
- }
683
- function resolveI18nText(value, locale, fallback, field, opts) {
684
- if (locale === "raw") {
685
- return value;
686
- }
687
- if (!locale) {
688
- throw new LocaleNotSpecifiedError(field ?? "<unknown>");
689
- }
690
- if (value[locale] !== void 0 && value[locale] !== "") {
691
- return value[locale];
692
- }
693
- const policy = opts?.policy ?? "throw";
694
- const callerChain = toChain(fallback);
695
- const callerHit = pickFromChain(value, callerChain);
696
- if (callerHit !== void 0) return callerHit;
697
- if (policy === "substitute") {
698
- const subHit = pickFromChain(value, toChain(opts?.substitute));
699
- if (subHit !== void 0) return subHit;
700
- if (opts?.smartSubstitute) {
701
- const smartHit = pickNearestScript(value, locale);
702
- if (smartHit !== void 0) return smartHit;
703
- }
704
- }
705
- if (policy === "throw") {
706
- throw new LocaleNotSpecifiedError(
707
- field ?? "<unknown>",
708
- `No translation available for locale "${locale}"` + (callerChain.length > 0 ? ` or fallback chain [${callerChain.join(", ")}]` : "") + "."
709
- );
710
- }
711
- return null;
712
- }
713
- function pickNearestScript(value, target) {
714
- const targetScript = inferScripts(target)[0] ?? "Latin";
715
- let best;
716
- for (const [loc, v] of Object.entries(value)) {
717
- if (typeof v !== "string" || v === "") continue;
718
- const s = inferScripts(loc)[0] ?? "Latin";
719
- const score = s === targetScript ? 0 : s === "Latin" ? 1 : 2;
720
- if (best === void 0 || score < best.score) best = { score, v };
721
- if (score === 0) break;
722
- }
723
- return best?.v;
724
- }
725
- function applyAtPath(obj, path, locale, fallback, opts) {
726
- const arrayIdx = path.indexOf("[].");
727
- if (arrayIdx !== -1) {
728
- const arrayKey = path.slice(0, arrayIdx);
729
- const restPath = path.slice(arrayIdx + 3);
730
- const arr = obj[arrayKey];
731
- if (!Array.isArray(arr)) return obj;
732
- return {
733
- ...obj,
734
- [arrayKey]: arr.map((item) => {
735
- if (!item || typeof item !== "object" || Array.isArray(item)) return item;
736
- return applyAtPath(item, restPath, locale, fallback, opts);
737
- })
738
- };
739
- }
740
- const dotIdx = path.indexOf(".");
741
- if (dotIdx !== -1) {
742
- const head = path.slice(0, dotIdx);
743
- const rest = path.slice(dotIdx + 1);
744
- const nested = obj[head];
745
- if (!nested || typeof nested !== "object" || Array.isArray(nested)) return obj;
746
- return {
747
- ...obj,
748
- [head]: applyAtPath(nested, rest, locale, fallback, opts)
749
- };
750
- }
751
- const raw = obj[path];
752
- if (raw === void 0 || raw === null) return obj;
753
- if (typeof raw !== "object" || Array.isArray(raw)) return obj;
754
- return {
755
- ...obj,
756
- [path]: resolveI18nText(raw, locale, fallback, path, opts)
757
- };
758
- }
759
- function applyI18nLocale(record, i18nFields, locale, fallback, layer = "read") {
760
- const fieldNames = Object.keys(i18nFields);
761
- if (fieldNames.length === 0) return record;
762
- let result = record;
763
- for (const [field, descriptor] of Object.entries(i18nFields)) {
764
- const { onMissing, substitute, smartSubstitute } = descriptor.options;
765
- const opts = {
766
- policy: resolvePolicy(onMissing, layer),
767
- ...substitute !== void 0 ? { substitute } : {},
768
- ...smartSubstitute ? { smartSubstitute } : {}
769
- };
770
- result = applyAtPath(result, field, locale, fallback, opts);
771
- }
772
- result = stripI18nFilled(result);
773
- return result;
774
- }
775
- function stripI18nFilled(record) {
776
- if (!Object.prototype.hasOwnProperty.call(record, "_i18nFilled")) return record;
777
- const rest = { ...record };
778
- delete rest._i18nFilled;
779
- return rest;
780
- }
781
-
782
- // src/query/join.ts
783
- var DEFAULT_JOIN_MAX_ROWS = 5e4;
784
- var JOIN_WARN_FRACTION = 0.8;
785
- function coerceRefKey(value) {
786
- if (value === null || value === void 0) return null;
787
- if (typeof value === "string") return value;
788
- if (typeof value === "number" || typeof value === "bigint") return String(value);
789
- return null;
790
- }
791
- var warnedDanglingKeys = /* @__PURE__ */ new Set();
792
- function warnOnceDangling(field, target, refId) {
793
- const key = `${field}\u2192${target}:${refId}`;
794
- if (warnedDanglingKeys.has(key)) return;
795
- warnedDanglingKeys.add(key);
796
- console.warn(
797
- `[noy-db] .join() encountered dangling ref in 'warn' mode: field "${field}" \u2192 "${target}:${refId}" not found. Attaching null.`
798
- );
799
- }
800
- var warnedCeilingKeys = /* @__PURE__ */ new Set();
801
- function warnCeilingApproaching(target, side, rows, maxRows) {
802
- const key = `${target}:${side}`;
803
- if (warnedCeilingKeys.has(key)) return;
804
- warnedCeilingKeys.add(key);
805
- const pct = Math.round(rows / maxRows * 100);
806
- console.warn(
807
- `[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.`
808
- );
809
- }
810
- function applyJoins(rows, joins, context, locale) {
811
- if (joins.length === 0) return [...rows];
812
- let result = [...rows];
813
- for (const leg of joins) {
814
- result = applyOneJoin(result, leg, context, locale);
815
- }
816
- return result;
817
- }
818
- function applyOneJoin(leftRows, leg, context, locale) {
819
- if (leg.isDictJoin) {
820
- const dictSource = context.resolveDictSource?.(leg.field);
821
- if (!dictSource) {
822
- throw new Error(
823
- `.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.`
824
- );
825
- }
826
- const out = [];
827
- const snapshot = dictSource.snapshot();
828
- const dictMap = /* @__PURE__ */ new Map();
829
- for (const entry of snapshot) {
830
- const k = readPath(entry, "key");
831
- if (typeof k === "string") dictMap.set(k, entry);
832
- }
833
- for (const left of leftRows) {
834
- const rawId = readPath(left, leg.field);
835
- const key = coerceRefKey(rawId);
836
- const dictEntry = key === null ? void 0 : dictMap.get(key);
837
- out.push({ ...left, [leg.as]: dictEntry ?? null });
838
- }
839
- return out;
840
- }
841
- const source = context.resolveSource(leg.target);
842
- if (!source) {
843
- throw new Error(
844
- `.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.`
845
- );
846
- }
847
- const maxRows = leg.maxRows ?? DEFAULT_JOIN_MAX_ROWS;
848
- if (leftRows.length > maxRows) {
849
- throw new JoinTooLargeError({
850
- leftRows: leftRows.length,
851
- rightRows: -1,
852
- maxRows,
853
- side: "left",
854
- 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.`
855
- });
856
- }
857
- if (leftRows.length > maxRows * JOIN_WARN_FRACTION) {
858
- warnCeilingApproaching(leg.target, "left", leftRows.length, maxRows);
859
- }
860
- const rightSnapshot = source.snapshot();
861
- if (rightSnapshot.length > maxRows) {
862
- throw new JoinTooLargeError({
863
- leftRows: leftRows.length,
864
- rightRows: rightSnapshot.length,
865
- maxRows,
866
- side: "right",
867
- 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.`
868
- });
869
- }
870
- if (rightSnapshot.length > maxRows * JOIN_WARN_FRACTION) {
871
- warnCeilingApproaching(leg.target, "right", rightSnapshot.length, maxRows);
872
- }
873
- const effLocale = locale ?? context.defaultLocale;
874
- const i18nResolve = effLocale !== void 0 && source.i18nFields !== void 0 ? (right) => right !== null && typeof right === "object" ? applyI18nLocale(right, source.i18nFields, effLocale, void 0, "join") : right : void 0;
875
- const strategy = leg.strategy ?? (source.lookupById ? "nested" : "hash");
876
- if (strategy === "nested" && source.lookupById) {
877
- const lookup = (id) => source.lookupById?.(id);
878
- return nestedLoopJoin(leftRows, leg, lookup, i18nResolve);
879
- }
880
- return hashJoin(leftRows, leg, rightSnapshot, i18nResolve);
881
- }
882
- function nestedLoopJoin(leftRows, leg, lookupById, i18nResolve) {
883
- const out = [];
884
- for (const left of leftRows) {
885
- const rawId = readPath(left, leg.field);
886
- const key = coerceRefKey(rawId);
887
- let right = key === null ? void 0 : lookupById(key);
888
- if (i18nResolve && right !== void 0) right = i18nResolve(right);
889
- out.push(attachJoin(left, leg, right, rawId));
890
- }
891
- return out;
892
- }
893
- function hashJoin(leftRows, leg, rightSnapshot, i18nResolve) {
894
- const rightMap = /* @__PURE__ */ new Map();
895
- for (const record of rightSnapshot) {
896
- const rawId = readPath(record, "id");
897
- const key = coerceRefKey(rawId);
898
- if (key !== null) {
899
- rightMap.set(key, record);
900
- }
901
- }
902
- const out = [];
903
- for (const left of leftRows) {
904
- const rawId = readPath(left, leg.field);
905
- const key = coerceRefKey(rawId);
906
- let right = key === null ? void 0 : rightMap.get(key);
907
- if (i18nResolve && right !== void 0) right = i18nResolve(right);
908
- out.push(attachJoin(left, leg, right, rawId));
909
- }
910
- return out;
911
- }
912
- function attachJoin(left, leg, right, rawId) {
913
- if (left === null || typeof left !== "object") {
914
- return left;
915
- }
916
- const merged = { ...left };
917
- const refKey = coerceRefKey(rawId);
918
- if (right === void 0) {
919
- if (refKey !== null && leg.mode === "strict") {
920
- throw new DanglingReferenceError({
921
- field: leg.field,
922
- target: leg.target,
923
- refId: refKey,
924
- 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.`
925
- });
926
- }
927
- if (refKey !== null && leg.mode === "warn") {
928
- warnOnceDangling(leg.field, leg.target, refKey);
929
- }
930
- merged[leg.as] = null;
931
- } else {
932
- merged[leg.as] = right;
933
- }
934
- return merged;
935
- }
936
- function resetJoinWarnings() {
937
- warnedDanglingKeys.clear();
938
- warnedCeilingKeys.clear();
939
- }
940
-
941
- // src/query/live.ts
942
- function buildLiveQuery(recompute, upstreams) {
943
- return new LiveQueryImpl(recompute, upstreams);
944
- }
945
- var LiveQueryImpl = class {
946
- constructor(recompute, upstreams) {
947
- this.recompute = recompute;
948
- this.refresh();
949
- for (const upstream of upstreams) {
950
- try {
951
- this.unsubs.push(upstream.subscribe(this.onUpstreamChange));
952
- } catch (err) {
953
- this._error = err instanceof Error ? err : new Error(String(err));
954
- }
955
- }
956
- }
957
- recompute;
958
- _value = [];
959
- _error = null;
960
- listeners = /* @__PURE__ */ new Set();
961
- unsubs = [];
962
- stopped = false;
963
- get value() {
964
- return this._value;
965
- }
966
- get error() {
967
- return this._error;
968
- }
969
- /**
970
- * Bound change handler — used as the callback passed to every
971
- * upstream's subscribe. Bound via class field so the `this`
972
- * context survives the indirect call from arbitrary upstreams.
973
- */
974
- onUpstreamChange = () => {
975
- this.refresh();
976
- for (const cb of this.listeners) {
977
- try {
978
- cb();
979
- } catch {
980
- }
981
- }
982
- };
983
- refresh() {
984
- if (this.stopped) return;
985
- try {
986
- this._value = this.recompute();
987
- this._error = null;
988
- } catch (err) {
989
- this._error = err instanceof Error ? err : new Error(String(err));
990
- }
991
- }
992
- subscribe(cb) {
993
- if (this.stopped) return () => {
994
- };
995
- this.listeners.add(cb);
996
- return () => this.listeners.delete(cb);
997
- }
998
- stop() {
999
- if (this.stopped) return;
1000
- this.stopped = true;
1001
- for (const unsub of this.unsubs) {
1002
- try {
1003
- unsub();
1004
- } catch {
1005
- }
1006
- }
1007
- this.unsubs.length = 0;
1008
- this.listeners.clear();
1009
- }
1010
- };
1011
-
1012
- // src/aggregate/strategy.ts
1013
- var NOT_ENABLED = new Error(
1014
- 'Aggregate / groupBy is not enabled on this Noydb instance. Import `{ withAggregate }` from "@noy-db/hub/aggregate" and pass it to `createNoydb({ aggregateStrategy: withAggregate() })`.'
1015
- );
1016
- var NO_AGGREGATE = {
1017
- aggregate() {
1018
- throw NOT_ENABLED;
1019
- },
1020
- groupBy() {
1021
- throw NOT_ENABLED;
1022
- },
1023
- groupByN() {
1024
- throw NOT_ENABLED;
1025
- },
1026
- scanAggregate() {
1027
- throw NOT_ENABLED;
1028
- }
1029
- };
1030
-
1031
- // src/money/money-reducer.ts
1032
- function toScaledIntFromAny(v, scale) {
1033
- if (typeof v === "bigint") return v;
1034
- if (typeof v === "number") {
1035
- const r = parseToScaledInt(v, scale);
1036
- return r.ok ? r.value : null;
1037
- }
1038
- if (typeof v === "string") {
1039
- if (!v.includes(".")) {
1040
- try {
1041
- return BigInt(v);
1042
- } catch {
1043
- return null;
1044
- }
1045
- }
1046
- const r = parseToScaledInt(v, scale);
1047
- return r.ok ? r.value : null;
1048
- }
1049
- return null;
1050
- }
1051
- function readMoney(record, field, desc) {
1052
- const raw = readPath(record, field);
1053
- if (raw === null || raw === void 0) return null;
1054
- if (desc.mode === "fixed") {
1055
- const cur = desc.fixedCurrency;
1056
- const value2 = toScaledIntFromAny(raw, desc.scaleFor(cur));
1057
- return value2 === null ? null : { currency: cur, value: value2 };
1058
- }
1059
- if (typeof raw !== "object") return null;
1060
- const o = raw;
1061
- if (typeof o.currency !== "string") return null;
1062
- const scale = desc.allows(o.currency) ? desc.scaleFor(o.currency) : 0;
1063
- const value = toScaledIntFromAny(o.amount, scale);
1064
- return value === null ? null : { currency: o.currency, value };
1065
- }
1066
- function targetScaleFor(desc, currency) {
1067
- if (desc.allows(currency)) return desc.scaleFor(currency);
1068
- const s = scaleForCurrency(currency);
1069
- if (s === null) {
1070
- throw new Error(`money: cannot determine scale for conversion target "${currency}"`);
1071
- }
1072
- return s;
1073
- }
1074
- function parseRate(rate) {
1075
- const s = String(rate).trim();
1076
- const neg = s.startsWith("-");
1077
- const body = neg ? s.slice(1) : s;
1078
- const dot = body.indexOf(".");
1079
- const intPart = dot === -1 ? body : body.slice(0, dot);
1080
- const fracPart = dot === -1 ? "" : body.slice(dot + 1);
1081
- const int = BigInt((intPart === "" ? "0" : intPart) + fracPart);
1082
- return { int: neg ? -int : int, scale: fracPart.length };
1083
- }
1084
- function divRoundHalfEven(n, d) {
1085
- const q = n / d;
1086
- const r = n % d;
1087
- const twiceR = (r < 0n ? -r : r) * 2n;
1088
- if (twiceR < d) return q;
1089
- if (twiceR > d) return q + (n < 0n ? -1n : 1n);
1090
- return q % 2n === 0n ? q : q + (n < 0n ? -1n : 1n);
1091
- }
1092
- function convertScaled(value, srcScale, rate, targetScale) {
1093
- const { int: rateInt, scale: rateScale } = parseRate(rate);
1094
- const product = value * rateInt;
1095
- const curScale = srcScale + rateScale;
1096
- if (curScale === targetScale) return product;
1097
- if (curScale < targetScale) return product * 10n ** BigInt(targetScale - curScale);
1098
- return divRoundHalfEven(product, 10n ** BigInt(curScale - targetScale));
1099
- }
1100
- function finalizeSum(state, desc, convertTo, fx) {
1101
- if (convertTo !== void 0) {
1102
- if (fx === void 0) {
1103
- throw new Error(`money: sum convertTo "${convertTo}" requires an fx rate map`);
1104
- }
1105
- const targetScale = targetScaleFor(desc, convertTo);
1106
- let total = 0n;
1107
- for (const [cur, v] of state) {
1108
- if (cur === convertTo) {
1109
- total += convertScaled(v, desc.scaleFor(cur), 1, targetScale);
1110
- continue;
1111
- }
1112
- const rate = fx[`${cur}->${convertTo}`];
1113
- if (rate === void 0) {
1114
- throw new Error(`money: no fx rate for "${cur}->${convertTo}"`);
1115
- }
1116
- total += convertScaled(v, desc.scaleFor(cur), rate, targetScale);
1117
- }
1118
- return formatScaledInt(total, targetScale);
1119
- }
1120
- if (desc.mode === "fixed") {
1121
- const cur = desc.fixedCurrency;
1122
- return formatScaledInt(state.get(cur) ?? 0n, desc.scaleFor(cur));
1123
- }
1124
- const out = {};
1125
- for (const [cur, v] of state) out[cur] = formatScaledInt(v, desc.scaleFor(cur));
1126
- return out;
1127
- }
1128
- function moneySumReducer(field, desc, convertTo, fx) {
1129
- return {
1130
- op: "sum",
1131
- field,
1132
- init: () => /* @__PURE__ */ new Map(),
1133
- step: (state, record) => {
1134
- const m = readMoney(record, field, desc);
1135
- if (m) state.set(m.currency, (state.get(m.currency) ?? 0n) + m.value);
1136
- return state;
1137
- },
1138
- remove: (state, record) => {
1139
- const m = readMoney(record, field, desc);
1140
- if (m) state.set(m.currency, (state.get(m.currency) ?? 0n) - m.value);
1141
- return state;
1142
- },
1143
- finalize: (state) => finalizeSum(state, desc, convertTo, fx)
1144
- };
1145
- }
1146
- function extremum(values, op) {
1147
- let out = values[0];
1148
- for (let i = 1; i < values.length; i++) {
1149
- const v = values[i];
1150
- if (op === "min" ? v < out : v > out) out = v;
1151
- }
1152
- return out;
1153
- }
1154
- function moneyMinMaxReducer(op, field, desc) {
1155
- return {
1156
- op,
1157
- field,
1158
- init: () => /* @__PURE__ */ new Map(),
1159
- step: (state, record) => {
1160
- const m = readMoney(record, field, desc);
1161
- if (m) {
1162
- const arr = state.get(m.currency);
1163
- if (arr) arr.push(m.value);
1164
- else state.set(m.currency, [m.value]);
1165
- }
1166
- return state;
1167
- },
1168
- remove: (state, record) => {
1169
- const m = readMoney(record, field, desc);
1170
- if (m) {
1171
- const arr = state.get(m.currency);
1172
- if (arr) {
1173
- const idx = arr.indexOf(m.value);
1174
- if (idx >= 0) arr.splice(idx, 1);
1175
- }
1176
- }
1177
- return state;
1178
- },
1179
- finalize: (state) => {
1180
- if (desc.mode === "fixed") {
1181
- const cur = desc.fixedCurrency;
1182
- const arr = state.get(cur);
1183
- if (!arr || arr.length === 0) return null;
1184
- return formatScaledInt(extremum(arr, op), desc.scaleFor(cur));
1185
- }
1186
- const out = {};
1187
- for (const [cur, arr] of state) {
1188
- if (arr.length > 0) out[cur] = formatScaledInt(extremum(arr, op), desc.scaleFor(cur));
1189
- }
1190
- return out;
1191
- }
1192
- };
1193
- }
1194
- function wrapMoneyReducers(spec, moneyFields) {
1195
- let changed = false;
1196
- const out = {};
1197
- for (const [key, reducer] of Object.entries(spec)) {
1198
- const field = reducer.field;
1199
- const desc = field ? moneyFields[field] : void 0;
1200
- if (desc && reducer.op === "avg") {
1201
- throw new MoneyUnsupportedError(
1202
- field,
1203
- `avg() is not supported on money field "${field}" in v1 \u2014 use sum() and count() and divide at the boundary.`
1204
- );
1205
- }
1206
- if (desc && (reducer.op === "sum" || reducer.op === "min" || reducer.op === "max")) {
1207
- changed = true;
1208
- out[key] = reducer.op === "sum" ? moneySumReducer(field, desc, reducer.convertTo, reducer.fx) : moneyMinMaxReducer(reducer.op, field, desc);
1209
- } else {
1210
- out[key] = reducer;
1211
- }
1212
- }
1213
- return changed ? out : spec;
1214
- }
1215
-
1216
- // src/money/paths.ts
1217
- var SEGMENT_RE = /^(\*|[^.[\]*]+)(\[\])?$/;
1218
- var parseCache = /* @__PURE__ */ new Map();
1219
- function parseMoneyPath(path) {
1220
- const cached = parseCache.get(path);
1221
- if (cached) return cached;
1222
- if (typeof path !== "string" || path.length === 0) {
1223
- throw new ValidationError("moneyFields: path must be a non-empty string");
1224
- }
1225
- const segments = [];
1226
- for (const part of path.split(".")) {
1227
- const m = SEGMENT_RE.exec(part);
1228
- if (!m) {
1229
- throw new ValidationError(
1230
- `moneyFields: invalid path "${path}" \u2014 segment "${part}" must be a key, "key[]", "*", or "*[]"`
1231
- );
1232
- }
1233
- const array = m[2] === "[]";
1234
- segments.push(
1235
- m[1] === "*" ? { kind: "wildcard", array } : { kind: "key", key: m[1], array }
1236
- );
1237
- }
1238
- parseCache.set(path, segments);
1239
- return segments;
1240
- }
1241
- function isSimpleMoneyPath(path) {
1242
- return !path.includes(".") && !path.includes("[") && !path.includes("*");
1243
- }
1244
- function transformAtMoneyPath(node, path, segments, index, visit, lenient) {
1245
- if (node === null || node === void 0) return node;
1246
- const seg = segments[index];
1247
- const last = index === segments.length - 1;
1248
- if (seg.kind === "key") {
1249
- if (typeof node !== "object" || Array.isArray(node)) {
1250
- if (lenient) return node;
1251
- throw new ValidationError(
1252
- `moneyFields: path "${path}" expected an object at segment "${seg.key}", got ${Array.isArray(node) ? "an array" : typeof node}`
1253
- );
1254
- }
1255
- const obj2 = node;
1256
- if (!(seg.key in obj2) || obj2[seg.key] === null || obj2[seg.key] === void 0) return node;
1257
- if (seg.array) {
1258
- const arr = obj2[seg.key];
1259
- if (!Array.isArray(arr)) {
1260
- if (lenient) return node;
1261
- throw new ValidationError(
1262
- `moneyFields: path "${path}" declares "${seg.key}[]" but the value is not an array`
1263
- );
1264
- }
1265
- const cloned = [...arr];
1266
- if (last) {
1267
- for (let i = 0; i < cloned.length; i++) visit(cloned, i);
1268
- } else {
1269
- for (let i = 0; i < cloned.length; i++) {
1270
- cloned[i] = transformAtMoneyPath(cloned[i], path, segments, index + 1, visit, lenient);
1271
- }
1272
- }
1273
- return { ...obj2, [seg.key]: cloned };
1274
- }
1275
- const clone2 = { ...obj2 };
1276
- if (last) {
1277
- visit(clone2, seg.key);
1278
- } else {
1279
- clone2[seg.key] = transformAtMoneyPath(clone2[seg.key], path, segments, index + 1, visit, lenient);
1280
- }
1281
- return clone2;
1282
- }
1283
- if (seg.array) {
1284
- if (!Array.isArray(node)) {
1285
- if (lenient) return node;
1286
- throw new ValidationError(`moneyFields: path "${path}" declares "*[]" but the value is not an array`);
1287
- }
1288
- const cloned = [...node];
1289
- if (last) {
1290
- for (let i = 0; i < cloned.length; i++) visit(cloned, i);
1291
- } else {
1292
- for (let i = 0; i < cloned.length; i++) {
1293
- cloned[i] = transformAtMoneyPath(cloned[i], path, segments, index + 1, visit, lenient);
1294
- }
1295
- }
1296
- return cloned;
1297
- }
1298
- if (typeof node !== "object" || Array.isArray(node)) {
1299
- if (lenient) return node;
1300
- throw new ValidationError(
1301
- `moneyFields: path "${path}" applies "*" to a non-object (${Array.isArray(node) ? 'array \u2014 use "*[]"' : typeof node})`
1302
- );
1303
- }
1304
- const obj = node;
1305
- const clone = { ...obj };
1306
- for (const key of Object.keys(obj)) {
1307
- const v = clone[key];
1308
- if (v === null || v === void 0) continue;
1309
- if (last) visit(clone, key);
1310
- else clone[key] = transformAtMoneyPath(v, path, segments, index + 1, visit, lenient);
1311
- }
1312
- return clone;
1313
- }
1314
-
1315
- // src/money/normalize.ts
1316
- function isMoneyValueObject2(v) {
1317
- return typeof v === "object" && v !== null && "currency" in v;
1318
- }
1319
- function formatCurrency(decimal, currency, scale, locale) {
1320
- const fmt = new Intl.NumberFormat(locale, {
1321
- style: "currency",
1322
- currency,
1323
- minimumFractionDigits: scale,
1324
- maximumFractionDigits: scale
1325
- });
1326
- return fmt.format(decimal);
1327
- }
1328
- function moneyScaledValue(stored, desc) {
1329
- let raw;
1330
- if (desc.mode === "fixed") {
1331
- raw = stored;
1332
- } else {
1333
- if (!isMoneyValueObject2(stored)) return null;
1334
- raw = stored.amount;
1335
- }
1336
- if (typeof raw !== "string" && typeof raw !== "number") return null;
1337
- try {
1338
- return BigInt(String(raw));
1339
- } catch {
1340
- return null;
1341
- }
1342
- }
1343
- function decodeValue(stored, desc) {
1344
- let currency;
1345
- let scaledIntString;
1346
- if (desc.mode === "fixed") {
1347
- if (typeof stored !== "string" && typeof stored !== "number") return null;
1348
- currency = desc.fixedCurrency;
1349
- scaledIntString = String(stored);
1350
- } else {
1351
- if (!isMoneyValueObject2(stored)) return null;
1352
- const amount = stored.amount;
1353
- if (typeof stored.currency !== "string" || typeof amount !== "string" && typeof amount !== "number") return null;
1354
- currency = stored.currency;
1355
- scaledIntString = String(amount);
1356
- }
1357
- const scale = desc.scaleFor(currency);
1358
- let decimal;
1359
- try {
1360
- decimal = formatScaledInt(BigInt(scaledIntString), scale);
1361
- } catch {
1362
- return null;
1363
- }
1364
- return {
1365
- decoded: desc.mode === "fixed" ? decimal : { amount: decimal, currency },
1366
- decimal,
1367
- currency,
1368
- scale
1369
- };
1370
- }
1371
- function decodeMoneyFields(record, moneyFields, locale) {
1372
- let out = { ...record };
1373
- const format = locale !== "raw";
1374
- const fmtLocale = typeof locale === "string" && locale !== "raw" ? locale : "en-US";
1375
- for (const [path, desc] of Object.entries(moneyFields)) {
1376
- if (isSimpleMoneyPath(path)) {
1377
- const stored = out[path];
1378
- if (stored === null || stored === void 0) continue;
1379
- const r = decodeValue(stored, desc);
1380
- if (r === null) continue;
1381
- out[path] = r.decoded;
1382
- if (format) {
1383
- out[`${path}Formatted`] = formatCurrency(r.decimal, r.currency, r.scale, fmtLocale);
1384
- out[`${path}Number`] = Number(r.decimal);
1385
- }
1386
- continue;
1387
- }
1388
- out = transformAtMoneyPath(
1389
- out,
1390
- path,
1391
- parseMoneyPath(path),
1392
- 0,
1393
- (container, key) => {
1394
- const stored = container[key];
1395
- if (stored === null || stored === void 0) return;
1396
- const r = decodeValue(stored, desc);
1397
- if (r === null) return;
1398
- container[key] = r.decoded;
1399
- if (format && typeof key === "string" && !Array.isArray(container)) {
1400
- container[`${key}Formatted`] = formatCurrency(r.decimal, r.currency, r.scale, fmtLocale);
1401
- container[`${key}Number`] = Number(r.decimal);
1402
- }
1403
- },
1404
- /* lenient */
1405
- true
1406
- );
1407
- }
1408
- return out;
1409
- }
1410
-
1411
- // src/query/builder.ts
1412
- var EMPTY_PLAN = {
1413
- clauses: [],
1414
- orderBy: [],
1415
- limit: void 0,
1416
- offset: 0,
1417
- joins: []
1418
- };
1419
- var DEFAULT_CROSS_JOIN_MAX_ROWS = 5e4;
1420
- var Query = class _Query {
1421
- source;
1422
- plan;
1423
- joinContext;
1424
- aggregateStrategy;
1425
- predicates;
1426
- constructor(source, plan = EMPTY_PLAN, joinContext, aggregateStrategy = NO_AGGREGATE, predicates) {
1427
- this.source = source;
1428
- this.plan = plan;
1429
- this.joinContext = joinContext;
1430
- this.aggregateStrategy = aggregateStrategy;
1431
- this.predicates = predicates;
1432
- }
1433
- /**
1434
- * @internal — accessor for the materialized-view dependency
1435
- * analyzer. Not part of the public API; consumers should use the
1436
- * builder methods, not inspect the plan directly.
1437
- */
1438
- _plan() {
1439
- return this.plan;
1440
- }
1441
- /**
1442
- * @internal — accessor for the materialized-view dependency
1443
- * analyzer. Returns the join resolution context (or `undefined` for
1444
- * queries constructed without a Collection backing).
1445
- */
1446
- _joinContext() {
1447
- return this.joinContext;
1448
- }
1449
- /**
1450
- * @internal — clone this Query with a declared-predicate map
1451
- * attached. Used by the materialized-view registry to enable
1452
- * `.wherePredicate(name, ctx?)` for the MV's query callback.
1453
- * Consumers don't call this directly.
1454
- */
1455
- _withPredicates(predicates) {
1456
- return new _Query(
1457
- this.source,
1458
- this.plan,
1459
- this.joinContext,
1460
- this.aggregateStrategy,
1461
- predicates
1462
- );
1463
- }
1464
- /**
1465
- * @internal — #308 L3. The ids of records matching this query's plan,
1466
- * recovered by reference identity: `executePlanWithSource` returns the
1467
- * ORIGINAL snapshot record references (money-decode and joins are applied
1468
- * later, in `toArray`), so each matched record is found in the id-paired
1469
- * `snapshotEntries()` map. Used by `collection.retrieve({ within })`.
1470
- * Throws if the source is not collection-backed (no `snapshotEntries`).
1471
- */
1472
- _idArray() {
1473
- const entries = this.source.snapshotEntries?.();
1474
- if (entries === void 0) {
1475
- throw new Error(
1476
- "Query._idArray(): the query source has no snapshotEntries(); retrieve({ within }) requires a collection-backed query (collection.query())."
1477
- );
1478
- }
1479
- if (this.plan.clauses.some((c) => c.type === "crossJoin")) {
1480
- throw new Error(
1481
- "Query._idArray(): retrieve({ within }) does not support crossJoin queries (cross-join produces new row objects, breaking id recovery). Use where/filter/and/or, or a projection .join()."
1482
- );
1483
- }
1484
- const refToId = /* @__PURE__ */ new Map();
1485
- for (const { id, record } of entries) refToId.set(record, id);
1486
- const matched = executePlanWithSource(this.source, this.plan, this.joinContext);
1487
- const ids = [];
1488
- for (const r of matched) {
1489
- const id = refToId.get(r);
1490
- if (id !== void 0) ids.push(id);
1491
- }
1492
- return ids;
1493
- }
1494
- /**
1495
- * Filter by a registered deterministic predicate. Requires
1496
- * the Query to have been augmented with a predicates map (typically
1497
- * via the materialized-view registry — bare Queries constructed
1498
- * outside an MV throw on `.wherePredicate()`).
1499
- *
1500
- * `ctx` is an optional opaque value passed verbatim to the predicate
1501
- * function. Both `predicateHash` (from the registration) and a
1502
- * canonical-JSON hash of `ctx` fold into the MV's `queryHash`, so
1503
- * either changing forces refresh on next visit.
1504
- */
1505
- wherePredicate(name, ctx) {
1506
- if (!this.predicates) {
1507
- throw new Error(
1508
- `.wherePredicate("${name}"): no predicates registered on this Query. Function-based predicates require the Query to be obtained from inside a materialized-view query() callback whose strategy declares \`predicates: { ${name}: { hash, fn } }\`.`
1509
- );
1510
- }
1511
- const decl = this.predicates.get(name);
1512
- if (!decl) {
1513
- throw new Error(
1514
- `.wherePredicate("${name}"): predicate not registered. Available: ${[...this.predicates.keys()].join(", ") || "(none)"}.`
1515
- );
1516
- }
1517
- const clause = {
1518
- type: "wherePredicate",
1519
- name,
1520
- ctx,
1521
- predicateHash: decl.hash,
1522
- ctxHash: canonicalCtxHash(ctx),
1523
- fn: decl.fn
1524
- };
1525
- return new _Query(
1526
- this.source,
1527
- { ...this.plan, clauses: [...this.plan.clauses, clause] },
1528
- this.joinContext,
1529
- this.aggregateStrategy,
1530
- this.predicates
1531
- );
1532
- }
1533
- /**
1534
- * Add a field comparison. Multiple where() calls are AND-combined.
1535
- *
1536
- * A declared money field compares in MAJOR units (#336): the operand
1537
- * (`10000`, `'10000.00'`, or `{ amount, currency }` in multi mode) is
1538
- * quantized into stored scaled-int space at build time and evaluated
1539
- * BigInt-exact per record. A malformed operand or a string operator
1540
- * (`contains`/`startsWith`) throws here, at the call site.
1541
- */
1542
- where(field, op, value) {
1543
- const desc = this.source.moneyFields?.[field];
1544
- const clause = desc ? moneyFieldClause(field, op, value, desc) : { type: "field", field, op, value };
1545
- return new _Query(
1546
- this.source,
1547
- { ...this.plan, clauses: [...this.plan.clauses, clause] },
1548
- this.joinContext,
1549
- this.aggregateStrategy,
1550
- this.predicates
1551
- );
1552
- }
1553
- /**
1554
- * Logical OR group. Pass a callback that builds a sub-query.
1555
- * Each clause inside the callback is OR-combined; the group itself
1556
- * joins the parent plan with AND.
1557
- */
1558
- or(builder) {
1559
- const sub = builder(
1560
- new _Query(this.source, EMPTY_PLAN, this.joinContext, this.aggregateStrategy, this.predicates)
1561
- );
1562
- const group = {
1563
- type: "group",
1564
- op: "or",
1565
- clauses: sub.plan.clauses
1566
- };
1567
- return new _Query(
1568
- this.source,
1569
- { ...this.plan, clauses: [...this.plan.clauses, group] },
1570
- this.joinContext,
1571
- this.aggregateStrategy,
1572
- this.predicates
1573
- );
1574
- }
1575
- /**
1576
- * Logical AND group. Same shape as `or()` but every clause inside the group
1577
- * must match. Useful for explicit grouping inside a larger OR.
1578
- */
1579
- and(builder) {
1580
- const sub = builder(
1581
- new _Query(this.source, EMPTY_PLAN, this.joinContext, this.aggregateStrategy, this.predicates)
1582
- );
1583
- const group = {
1584
- type: "group",
1585
- op: "and",
1586
- clauses: sub.plan.clauses
1587
- };
1588
- return new _Query(
1589
- this.source,
1590
- { ...this.plan, clauses: [...this.plan.clauses, group] },
1591
- this.joinContext,
1592
- this.aggregateStrategy,
1593
- this.predicates
1594
- );
1595
- }
1596
- /** Escape hatch: add an arbitrary predicate function. Not serializable. */
1597
- filter(fn) {
1598
- const clause = {
1599
- type: "filter",
1600
- fn
1601
- };
1602
- return new _Query(
1603
- this.source,
1604
- { ...this.plan, clauses: [...this.plan.clauses, clause] },
1605
- this.joinContext,
1606
- this.aggregateStrategy,
1607
- this.predicates
1608
- );
1609
- }
1610
- /**
1611
- * Sort by a field. Subsequent calls are tie-breakers. Pass
1612
- * `{ by: 'label' }` to sort a `dictKey`/`staticDict` field by its resolved
1613
- * label at the query locale instead of the stored code (#285).
1614
- */
1615
- orderBy(field, direction = "asc", opts) {
1616
- const entry = opts?.by === "label" ? { field, direction, by: "label" } : { field, direction };
1617
- return new _Query(
1618
- this.source,
1619
- { ...this.plan, orderBy: [...this.plan.orderBy, entry] },
1620
- this.joinContext,
1621
- this.aggregateStrategy,
1622
- this.predicates
1623
- );
1624
- }
1625
- /** Cap the result size. */
1626
- limit(n) {
1627
- return new _Query(
1628
- this.source,
1629
- { ...this.plan, limit: n },
1630
- this.joinContext,
1631
- this.aggregateStrategy,
1632
- this.predicates
1633
- );
1634
- }
1635
- /** Skip the first N matching records (after ordering). */
1636
- offset(n) {
1637
- return new _Query(
1638
- this.source,
1639
- { ...this.plan, offset: n },
1640
- this.joinContext,
1641
- this.aggregateStrategy,
1642
- this.predicates
1643
- );
1644
- }
1645
- /**
1646
- * Resolve a `ref()`-declared foreign key and attach the right-side
1647
- * record under `opts.as`. — eager, single-FK, intra-
1648
- * vault joins.
1649
- *
1650
- * ```ts
1651
- * const rows = invoices.query()
1652
- * .where('status', '==', 'open')
1653
- * .join('clientId', { as: 'client' })
1654
- * .toArray()
1655
- * // → [{ id, amount, client: { id, name, ... } }, ...]
1656
- * ```
1657
- *
1658
- * Preconditions:
1659
- * - The Query must have a `joinContext` (constructed via
1660
- * `Collection.query()`, not `new Query`).
1661
- * - `field` must have a matching `refs: { [field]: ref('<target>') }`
1662
- * declaration on the left collection.
1663
- * - The target collection must be reachable via the vault
1664
- * (either currently open or openable on demand).
1665
- *
1666
- * Strategy:
1667
- * - Nested-loop against `lookupById` when the target source
1668
- * provides it (the common path for Collection targets).
1669
- * - Hash join otherwise, or when `{ strategy: 'hash' }` is
1670
- * explicitly passed for test purposes.
1671
- *
1672
- * Ref-mode semantics on dangling refs (left record has a non-null
1673
- * FK value pointing at a right-side id that doesn't exist):
1674
- * - `strict` → throws `DanglingReferenceError` with the full
1675
- * field / target / refId context.
1676
- * - `warn` → attaches `null` and emits a one-shot warning per
1677
- * unique dangling pair.
1678
- * - `cascade` → attaches `null` silently. Cascade is a
1679
- * delete-time mode; dangling refs visible at read time are
1680
- * either mid-flight cascades or pre-existing orphans, not a
1681
- * DSL-level error.
1682
- *
1683
- * A left-side record whose FK field is `null` / `undefined` is NOT
1684
- * a dangling ref — it's "no reference at all", always allowed
1685
- * regardless of mode.
1686
- *
1687
- * The return type widens `T` with `Record<As, R | null>`. The `R`
1688
- * parameter is optional — supply it explicitly for type-checked
1689
- * access to the joined fields:
1690
- *
1691
- * ```ts
1692
- * invoices.query().join<'client', Client>('clientId', { as: 'client' })
1693
- * // ^^^^^^^^^^^^^^^^^^^ alias literal + right-side type
1694
- * ```
1695
- *
1696
- * Without the generic, the joined field is typed as `unknown`, which
1697
- * still works but requires a cast to access its properties.
1698
- *
1699
- * Joins stay intra-vault by construction — cross-vault
1700
- * correlation goes through `Noydb.queryAcross`, not
1701
- * `.join()`.
1702
- */
1703
- join(field, opts) {
1704
- if (!this.joinContext) {
1705
- throw new Error(
1706
- `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).`
1707
- );
1708
- }
1709
- const descriptor = this.joinContext.resolveRef(field);
1710
- const isDictJoinField = !descriptor && this.joinContext.resolveDictSource?.(field) != null;
1711
- if (!descriptor && !isDictJoinField) {
1712
- throw new Error(
1713
- `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.`
1714
- );
1715
- }
1716
- const leg = descriptor ? {
1717
- field,
1718
- as: opts.as,
1719
- target: descriptor.target,
1720
- mode: descriptor.mode,
1721
- strategy: opts.strategy,
1722
- maxRows: opts.maxRows,
1723
- // constraint #1 — always 'all' in. Do not remove.
1724
- partitionScope: "all"
1725
- } : {
1726
- // Dict join leg
1727
- field,
1728
- as: opts.as,
1729
- target: field,
1730
- // dict name = field name for dictKey
1731
- mode: "strict",
1732
- strategy: opts.strategy,
1733
- maxRows: opts.maxRows,
1734
- partitionScope: "all",
1735
- isDictJoin: true
1736
- };
1737
- return new _Query(
1738
- this.source,
1739
- { ...this.plan, joins: [...this.plan.joins, leg] },
1740
- this.joinContext,
1741
- this.aggregateStrategy,
1742
- this.predicates
1743
- );
1744
- }
1745
- /**
1746
- * Cartesian-product cross-join against `target` collection. Each result row
1747
- * carries the original `T` fields plus `result[as]` populated from every
1748
- * right-side row (or the filtered subset when `on:` is supplied).
1749
- *
1750
- * **Order matters:** `.where().crossJoin()` filters BEFORE expanding (cheaper);
1751
- * `.crossJoin().where('alias.field', ...)` filters AFTER (required when the
1752
- * where clause references the aliased fields).
1753
- *
1754
- * **Cost ceiling:** `CrossJoinTooLargeError` fires before allocation when
1755
- * `leftRows × rightRows` (or the cumulative lateral count) exceeds the limit.
1756
- * Default: 50,000 rows. Override per-clause with `{ maxRows: N }`.
1757
- *
1758
- * **`on:` shapes:**
1759
- * - `on: (left) => TTarget[]` — subset form (most efficient)
1760
- * - `on: (left) => (right) => boolean` — predicate form
1761
- * - `on: { predicate: 'name' }` — MV-safe, hash-tracked form
1762
- * (requires the Query to have been augmented via `_withPredicates`)
1763
- *
1764
- * Requires a JoinContext (constructed via `collection.query()`).
1765
- */
1766
- crossJoin(target, opts) {
1767
- if (!this.joinContext) {
1768
- throw new Error(
1769
- `Query.crossJoin("${target}"): requires a join context. Use collection.query() to construct a cross-join-capable Query instead of the Query constructor directly.`
1770
- );
1771
- }
1772
- let onFn;
1773
- let onPredicateName;
1774
- if (opts.on !== void 0) {
1775
- if (typeof opts.on === "function") {
1776
- onFn = opts.on;
1777
- if (this.predicates) {
1778
- console.warn(
1779
- `Query.crossJoin("${target}", { on: callback }): inline on: callback inside a withMaterializedView query() disables queryHash drift detection for this cross-join. Use on: { predicate: '<name>' } to enable it.`
1780
- );
1781
- }
1782
- } else {
1783
- const predName = opts.on.predicate;
1784
- if (!this.predicates) {
1785
- throw new Error(
1786
- `Query.crossJoin("${target}", { on: { predicate: "${predName}" } }): the { predicate } form requires a predicates map. Use this form inside a withMaterializedView query() callback that declares predicates: { ${predName}: { hash, fn } }.`
1787
- );
1788
- }
1789
- const decl = this.predicates.get(predName);
1790
- if (!decl) {
1791
- throw new Error(
1792
- `Query.crossJoin("${target}"): predicate "${predName}" not registered. Available: ${[...this.predicates.keys()].join(", ") || "(none)"}.`
1793
- );
1794
- }
1795
- const as = opts.as;
1796
- const predicateFn = decl.fn;
1797
- onFn = (_left) => (right) => predicateFn({ ..._left, [as]: right });
1798
- onPredicateName = predName;
1799
- }
1800
- }
1801
- const clause = {
1802
- type: "crossJoin",
1803
- target,
1804
- as: opts.as,
1805
- ...onFn !== void 0 && { on: onFn },
1806
- ...onPredicateName !== void 0 && { onPredicateName },
1807
- ...opts.maxRows !== void 0 && { maxRows: opts.maxRows }
1808
- };
1809
- return new _Query(
1810
- this.source,
1811
- { ...this.plan, clauses: [...this.plan.clauses, clause] },
1812
- this.joinContext,
1813
- this.aggregateStrategy,
1814
- this.predicates
1815
- );
1816
- }
1817
- /**
1818
- * Execute the plan and return the matching records. When the plan
1819
- * carries any join legs, they are applied after `where` / `orderBy`
1820
- * / `limit` / `offset` narrow the left set. See the `.join()` doc
1821
- * for the ordering rationale.
1822
- *
1823
- * `opts.locale` (#285 §3) resolves JOINED right-side i18n fields at the
1824
- * `join` layer to that locale; without it, the owning collection's default
1825
- * locale applies, and a locale-less query leaves joined i18n fields raw.
1826
- * (Left/base i18n fields are resolved by `get`/`list`, not here.)
1827
- */
1828
- toArray(opts) {
1829
- const base = this.decodeMoney(executePlanWithSource(this.source, this.plan, this.joinContext, opts?.locale));
1830
- if (this.plan.joins.length === 0) return base;
1831
- if (!this.joinContext) {
1832
- throw new Error(
1833
- `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.`
1834
- );
1835
- }
1836
- return applyJoins(base, this.plan.joins, this.joinContext, opts?.locale);
1837
- }
1838
- /**
1839
- * Decode this source's money fields on read (stored scaled-int → canonical
1840
- * decimal), so `query().toArray()` agrees with `get()`/`sum()` on the value.
1841
- * No-op when the source declares no money fields.
1842
- *
1843
- * The query layer carries no locale context, so we decode with `'raw'` —
1844
- * canonical decimal, WITHOUT fabricating locale-formatted `<field>Formatted`
1845
- * / `<field>Number` virtuals. Producing a guessed-locale string here would
1846
- * just reintroduce #322's "two read paths disagree" failure on the virtual
1847
- * field (e.g. it-IT via `get()` vs en-US here). Consumers who need formatted
1848
- * money read through `get()`/`list()` with a locale.
1849
- */
1850
- decodeMoney(records) {
1851
- const moneyFields = this.source.moneyFields;
1852
- if (!moneyFields || Object.keys(moneyFields).length === 0) return records;
1853
- return records.map((r) => decodeMoneyFields(r, moneyFields, "raw"));
1854
- }
1855
- /** Return the first matching record, or null. Joins are applied. `opts.locale` resolves joined i18n fields (#285 §3). */
1856
- first(opts) {
1857
- const arr = this.limit(1).toArray(opts);
1858
- return arr[0] ?? null;
1859
- }
1860
- /**
1861
- * Return the number of matching records (after where/filter,
1862
- * before limit). **Joins are NOT applied** — count() reports the
1863
- * left-side cardinality, because joins in are projection-only
1864
- * (they attach an aliased field; they never filter). Running joins
1865
- * here just to discard the aliases would be wasteful, and in strict
1866
- * mode it could throw `DanglingReferenceError` for a call whose
1867
- * intent is purely to count.
1868
- */
1869
- count() {
1870
- if (this.plan.clauses.some((c) => c.type === "crossJoin")) {
1871
- if (!this.joinContext) {
1872
- throw new Error(
1873
- `Query.count(): plan contains crossJoin clauses but no JoinContext is attached.`
1874
- );
1875
- }
1876
- return executeClausePipeline(this.source, this.plan.clauses, this.joinContext).length;
1877
- }
1878
- const { candidates, remainingClauses } = candidateRecords(this.source, this.plan.clauses);
1879
- if (remainingClauses.length === 0) return candidates.length;
1880
- return filterRecords(candidates, remainingClauses, fnViewDecoder(this.source)).length;
1881
- }
1882
- /**
1883
- * Reduce the matching records through a named set of reducers.
1884
- * the aggregation terminal.
1885
- *
1886
- * ```ts
1887
- * const { total, n, avgAmount } = invoices.query()
1888
- * .where('status', '==', 'open')
1889
- * .aggregate({
1890
- * total: sum('amount'),
1891
- * n: count(),
1892
- * avgAmount: avg('amount'),
1893
- * })
1894
- * .run()
1895
- * ```
1896
- *
1897
- * Returns an `Aggregation<R>` wrapper with two terminals:
1898
- * - `.run(): R` — synchronous one-shot reduction
1899
- * - `.live(): LiveAggregation<R>` — reactive primitive that
1900
- * re-runs the reduction whenever the source notifies of a
1901
- * change. Always call `live.stop()` when finished.
1902
- *
1903
- * The reducer spec is bound here once and reused by both
1904
- * terminals — this is why `.aggregate()` returns a wrapper instead
1905
- * of being a direct terminal. Consumers who only need the static
1906
- * value read `.run()`; consumers wiring a reactive UI read
1907
- * `.live()`.
1908
- *
1909
- * Joins are intentionally NOT applied to aggregations in —
1910
- * the same logic as `.count()`. Joins in are projection-only
1911
- * (they attach an aliased field and never filter), so running
1912
- * them just to throw the aliases away would be wasteful. If you
1913
- * need a reducer that reads a joined field, open an issue —
1914
- * aggregations-across-joins is explicitly out of scope for v1.
1915
- *
1916
- * Every reducer factory accepts an optional `{ seed }` parameter
1917
- * that is plumbed through the protocol but unused by the
1918
- * executor — that's constraint #2. When partition-aware
1919
- * aggregation lands, the seed will carry running state across
1920
- * partition boundaries without an API break.
1921
- */
1922
- aggregate(spec) {
1923
- const moneyFields = this.source.moneyFields;
1924
- if (moneyFields) {
1925
- spec = wrapMoneyReducers(spec, moneyFields);
1926
- }
1927
- const source = this.source;
1928
- const clauses = this.plan.clauses;
1929
- const joinCtx = this.joinContext;
1930
- const hasCrossJoins = clauses.some((c) => c.type === "crossJoin");
1931
- const executeRecords = () => {
1932
- if (hasCrossJoins) {
1933
- if (!joinCtx) throw new Error("Query.aggregate(): crossJoin requires a join context");
1934
- return executeClausePipeline(source, clauses, joinCtx);
1935
- }
1936
- const { candidates, remainingClauses } = candidateRecords(source, clauses);
1937
- return remainingClauses.length === 0 ? candidates : filterRecords(candidates, remainingClauses, fnViewDecoder(source));
1938
- };
1939
- const upstreams = [];
1940
- if (source.subscribe) {
1941
- const subscribe = source.subscribe.bind(source);
1942
- upstreams.push({ subscribe: (cb) => subscribe(cb) });
1943
- }
1944
- return this.aggregateStrategy.aggregate(executeRecords, spec, upstreams);
1945
- }
1946
- groupBy(...fields) {
1947
- if (fields.length === 0) {
1948
- throw new Error(".groupBy() requires at least one field");
1949
- }
1950
- const source = this.source;
1951
- const clauses = this.plan.clauses;
1952
- const joinCtx = this.joinContext;
1953
- const hasCrossJoins = clauses.some((c) => c.type === "crossJoin");
1954
- const executeRecords = () => {
1955
- if (hasCrossJoins) {
1956
- if (!joinCtx) throw new Error("Query.groupBy(): crossJoin requires a join context");
1957
- return executeClausePipeline(source, clauses, joinCtx);
1958
- }
1959
- const { candidates, remainingClauses } = candidateRecords(source, clauses);
1960
- return remainingClauses.length === 0 ? candidates : filterRecords(candidates, remainingClauses, fnViewDecoder(source));
1961
- };
1962
- const upstreams = [];
1963
- if (source.subscribe) {
1964
- const subscribe = source.subscribe.bind(source);
1965
- upstreams.push({ subscribe: (cb) => subscribe(cb) });
1966
- }
1967
- if (fields.length === 1) {
1968
- const field = fields[0];
1969
- const dictLabelResolver = buildDictLabelResolver(this.joinContext, field);
1970
- return this.aggregateStrategy.groupBy(
1971
- executeRecords,
1972
- field,
1973
- upstreams,
1974
- dictLabelResolver,
1975
- this.source.moneyFields
1976
- );
1977
- }
1978
- return this.aggregateStrategy.groupByN(
1979
- executeRecords,
1980
- fields,
1981
- upstreams,
1982
- this.source.moneyFields
1983
- );
1984
- }
1985
- /**
1986
- * Re-run the query whenever the source notifies of changes.
1987
- * Returns an unsubscribe function. The callback receives the latest result.
1988
- * Throws if the source does not support subscriptions.
1989
- *
1990
- * **For joined queries, prefer `.live()`** — `subscribe()`
1991
- * only re-fires on LEFT-side changes, so joined data can be
1992
- * stale if the right side mutates between emissions. `.live()`
1993
- * merges change streams from every join target.
1994
- */
1995
- subscribe(cb) {
1996
- if (!this.source.subscribe) {
1997
- throw new Error("Query source does not support subscriptions. Pass a source with a subscribe() method.");
1998
- }
1999
- cb(this.toArray());
2000
- return this.source.subscribe(() => cb(this.toArray()));
2001
- }
2002
- /**
2003
- * Reactive terminal — returns a `LiveQuery<T>` that re-runs the
2004
- * query and updates its `value` whenever any source feeding it
2005
- * mutates..
2006
- *
2007
- * For non-joined queries, `.live()` is a convenience over the
2008
- * existing `.subscribe()` callback shape: a hand-rolled reactive
2009
- * primitive with `value` / `error` fields and a `subscribe(cb)`
2010
- * notification channel. Frame-agnostic — Vue / React / Solid
2011
- * adapters wrap it in their own primitive.
2012
- *
2013
- * For joined queries, `.live()` additionally subscribes to every
2014
- * join target's change stream. Mutations on a right-side
2015
- * collection (insert / update / delete of a client referenced by
2016
- * an invoice) re-fire the live query and re-evaluate every
2017
- * dependent left row. Right-side targets are deduped by
2018
- * collection name, so a chain that joins the same target twice
2019
- * (e.g. billing client + shipping client → both 'clients') only
2020
- * subscribes once.
2021
- *
2022
- * **Ref-mode behavior on right-side disappearance** — matches the
2023
- * eager `.toArray()` contract from :
2024
- * - `strict` → re-run throws `DanglingReferenceError`. The
2025
- * LiveQuery catches the throw, stores it in `live.error`, and
2026
- * notifies listeners (the throw does NOT propagate out of
2027
- * the source's change handler — that would tear down the
2028
- * emitter). Consumers check `live.error` after each
2029
- * notification and render an error state in the UI.
2030
- * - `warn` → joined value flips to `null`; the existing
2031
- * warn-channel deduplication keeps repeated re-runs from
2032
- * spamming the console.
2033
- * - `cascade` → no special handling needed; the cascade-
2034
- * delete mechanism propagates the right-side delete into the
2035
- * left collection on the next tick, and the live query
2036
- * naturally re-fires with the orphaned left rows gone.
2037
- *
2038
- * Always call `live.stop()` when finished — it tears down every
2039
- * upstream subscription. The Vue layer's `onUnmounted` hook
2040
- * should call `stop()` automatically; raw consumers must do it
2041
- * themselves.
2042
- *
2043
- * **Limitations:**
2044
- * - No granular delta updates — the whole query re-runs on
2045
- * every change.
2046
- * - No microtask batching — bursty changes produce one re-run
2047
- * per change.
2048
- * - No re-planning under live mutations — the planner picks
2049
- * once at subscription time and reuses the same plan.
2050
- * - Streaming live joins are deferred.
2051
- */
2052
- live() {
2053
- const upstreams = [];
2054
- if (this.source.subscribe) {
2055
- const leftSubscribe = this.source.subscribe.bind(this.source);
2056
- upstreams.push({
2057
- subscribe: (cb) => leftSubscribe(cb)
2058
- });
2059
- }
2060
- if (this.plan.joins.length > 0 && this.joinContext) {
2061
- const subscribed = /* @__PURE__ */ new Set();
2062
- for (const leg of this.plan.joins) {
2063
- if (subscribed.has(leg.target)) continue;
2064
- subscribed.add(leg.target);
2065
- const rightSource = this.joinContext.resolveSource(leg.target);
2066
- if (rightSource?.subscribe) {
2067
- const rightSubscribe = rightSource.subscribe.bind(rightSource);
2068
- upstreams.push({
2069
- subscribe: (cb) => rightSubscribe(cb)
2070
- });
2071
- }
2072
- }
2073
- }
2074
- if (this.joinContext) {
2075
- const subscribedCross = /* @__PURE__ */ new Set();
2076
- for (const clause of this.plan.clauses) {
2077
- if (clause.type !== "crossJoin") continue;
2078
- if (subscribedCross.has(clause.target)) continue;
2079
- subscribedCross.add(clause.target);
2080
- const rightSource = this.joinContext.resolveSource(clause.target);
2081
- if (rightSource?.subscribe) {
2082
- const rightSubscribe = rightSource.subscribe.bind(rightSource);
2083
- upstreams.push({
2084
- subscribe: (cb) => rightSubscribe(cb)
2085
- });
2086
- }
2087
- }
2088
- }
2089
- return buildLiveQuery(() => this.toArray(), upstreams);
2090
- }
2091
- /**
2092
- * Return the plan as a JSON-friendly object. FilterClause entries are
2093
- * stripped (their `fn` cannot be serialized) and replaced with
2094
- * { type: 'filter', fn: '[function]' } so devtools can still see them.
2095
- */
2096
- toPlan() {
2097
- return serializePlan(this.plan);
2098
- }
2099
- };
2100
- function executePlanWithSource(source, plan, joinContext, locale) {
2101
- const hasCrossJoins = plan.clauses.some((c) => c.type === "crossJoin");
2102
- let result;
2103
- if (hasCrossJoins) {
2104
- if (!joinContext) {
2105
- throw new Error(
2106
- `Query.toArray(): plan contains crossJoin clauses but no JoinContext is attached. Use collection.query() instead of new Query() for cross-join support.`
2107
- );
2108
- }
2109
- result = executeClausePipeline(source, plan.clauses, joinContext);
2110
- } else {
2111
- const { candidates, remainingClauses } = candidateRecords(source, plan.clauses);
2112
- result = remainingClauses.length === 0 ? [...candidates] : filterRecords(candidates, remainingClauses, fnViewDecoder(source));
2113
- }
2114
- if (plan.orderBy.length > 0) {
2115
- const labelMaps = buildOrderLabelMaps(plan.orderBy, joinContext, locale);
2116
- result = sortRecords(result, plan.orderBy, source.moneyFields, labelMaps);
2117
- }
2118
- if (plan.offset > 0) {
2119
- result = result.slice(plan.offset);
2120
- }
2121
- if (plan.limit !== void 0) {
2122
- result = result.slice(0, plan.limit);
2123
- }
2124
- return result;
2125
- }
2126
- function candidateRecords(source, clauses) {
2127
- const indexes = source.getIndexes?.();
2128
- if (!indexes || !source.lookupById || clauses.length === 0) {
2129
- return { candidates: source.snapshot(), remainingClauses: clauses };
2130
- }
2131
- const lookupById = (id) => source.lookupById?.(id);
2132
- for (let i = 0; i < clauses.length; i++) {
2133
- const clause = clauses[i];
2134
- if (clause.type !== "field") continue;
2135
- if (!indexes.has(clause.field)) continue;
2136
- if (clause.money?.mode === "multi") continue;
2137
- let ids = null;
2138
- if (clause.op === "==") {
2139
- ids = indexes.lookupEqual(clause.field, clause.value);
2140
- } else if (clause.op === "in" && Array.isArray(clause.value)) {
2141
- ids = indexes.lookupIn(clause.field, clause.value);
2142
- }
2143
- if (ids !== null) {
2144
- const remaining = [];
2145
- for (let j = 0; j < clauses.length; j++) {
2146
- if (j !== i) remaining.push(clauses[j]);
2147
- }
2148
- return {
2149
- candidates: materializeIds(ids, lookupById),
2150
- remainingClauses: remaining
2151
- };
2152
- }
2153
- }
2154
- return { candidates: source.snapshot(), remainingClauses: clauses };
2155
- }
2156
- function materializeIds(ids, lookupById) {
2157
- const out = [];
2158
- for (const id of ids) {
2159
- const record = lookupById(id);
2160
- if (record !== void 0) out.push(record);
2161
- }
2162
- return out;
2163
- }
2164
- function executePlan(records, plan) {
2165
- if (plan.clauses.some((c) => c.type === "crossJoin")) {
2166
- throw new Error(
2167
- `executePlan(): does not support crossJoin clauses. executePlan is a stateless pure function \u2014 it cannot resolve cross-join right-side collections. Use Query.toArray() (via collection.query()) instead.`
2168
- );
2169
- }
2170
- let result = filterRecords(records, plan.clauses);
2171
- if (plan.orderBy.length > 0) {
2172
- result = sortRecords(result, plan.orderBy);
2173
- }
2174
- if (plan.offset > 0) {
2175
- result = result.slice(plan.offset);
2176
- }
2177
- if (plan.limit !== void 0) {
2178
- result = result.slice(0, plan.limit);
2179
- }
2180
- return result;
2181
- }
2182
- function fnViewDecoder(source) {
2183
- const mf = source.moneyFields;
2184
- if (!mf || Object.keys(mf).length === 0) return void 0;
2185
- return (r) => decodeMoneyFields(r, mf, "raw");
2186
- }
2187
- function filterRecords(records, clauses, decodeForFns) {
2188
- if (clauses.length === 0) return [...records];
2189
- const needsFnView = decodeForFns !== void 0 && hasFnClause(clauses);
2190
- const out = [];
2191
- for (const r of records) {
2192
- const fnView = needsFnView ? decodeForFns(r) : void 0;
2193
- let matches = true;
2194
- for (const clause of clauses) {
2195
- if (!evaluateClause(r, clause, fnView)) {
2196
- matches = false;
2197
- break;
2198
- }
2199
- }
2200
- if (matches) out.push(r);
2201
- }
2202
- return out;
2203
- }
2204
- function executeClausePipeline(source, clauses, joinContext) {
2205
- let rel = [...source.snapshot()];
2206
- let filterBatch = [];
2207
- const decodeForFns = fnViewDecoder(source);
2208
- for (const clause of clauses) {
2209
- if (clause.type === "crossJoin") {
2210
- if (filterBatch.length > 0) {
2211
- rel = filterRecords(rel, filterBatch, decodeForFns);
2212
- filterBatch = [];
2213
- }
2214
- const rightSource = joinContext.resolveSource(clause.target);
2215
- if (!rightSource) {
2216
- throw new CrossJoinSourceUnknownError(clause.target, joinContext.leftCollection);
2217
- }
2218
- rel = applyCrossJoin(rel, clause, rightSource);
2219
- } else {
2220
- filterBatch.push(clause);
2221
- }
2222
- }
2223
- if (filterBatch.length > 0) {
2224
- rel = filterRecords(rel, filterBatch, decodeForFns);
2225
- }
2226
- return rel;
2227
- }
2228
- function applyCrossJoin(leftRel, clause, rightSource) {
2229
- const rightRows = rightSource.snapshot();
2230
- const maxRows = clause.maxRows ?? DEFAULT_CROSS_JOIN_MAX_ROWS;
2231
- const { as } = clause;
2232
- if (!clause.on) {
2233
- const product = leftRel.length * rightRows.length;
2234
- if (product > maxRows) {
2235
- throw new CrossJoinTooLargeError({ target: clause.target, expected: product, limit: maxRows });
2236
- }
2237
- const expanded2 = [];
2238
- for (const left of leftRel) {
2239
- const leftObj = left;
2240
- for (const right of rightRows) {
2241
- expanded2.push({ ...leftObj, [as]: right });
2242
- }
2243
- }
2244
- return expanded2;
2245
- }
2246
- const expanded = [];
2247
- let cumulative = 0;
2248
- for (const left of leftRel) {
2249
- const callbackResult = clause.on(left);
2250
- let filteredRight;
2251
- if (Array.isArray(callbackResult)) {
2252
- filteredRight = callbackResult;
2253
- } else {
2254
- filteredRight = rightRows.filter(
2255
- callbackResult
2256
- );
2257
- }
2258
- cumulative += filteredRight.length;
2259
- if (cumulative > maxRows) {
2260
- throw new CrossJoinTooLargeError({
2261
- target: clause.target,
2262
- expected: cumulative,
2263
- limit: maxRows
2264
- });
2265
- }
2266
- const leftObj = left;
2267
- for (const right of filteredRight) {
2268
- expanded.push({ ...leftObj, [as]: right });
2269
- }
2270
- }
2271
- return expanded;
2272
- }
2273
- function sortRecords(records, orderBy, moneyFields, labelMaps) {
2274
- return [...records].sort((a, b) => {
2275
- for (const { field, direction, by } of orderBy) {
2276
- let av = readField(a, field);
2277
- let bv = readField(b, field);
2278
- const labelMap = by === "label" ? labelMaps?.get(field) : void 0;
2279
- if (labelMap) {
2280
- av = (typeof av === "string" ? labelMap.get(av) : void 0) ?? av;
2281
- bv = (typeof bv === "string" ? labelMap.get(bv) : void 0) ?? bv;
2282
- const cmp2 = compareValues(av, bv);
2283
- if (cmp2 !== 0) return direction === "asc" ? cmp2 : -cmp2;
2284
- continue;
2285
- }
2286
- const desc = moneyFields?.[field];
2287
- const cmp = desc ? compareMoney(av, bv, desc) : compareValues(av, bv);
2288
- if (cmp !== 0) return direction === "asc" ? cmp : -cmp;
2289
- }
2290
- return 0;
2291
- });
2292
- }
2293
- function buildOrderLabelMaps(orderBy, joinContext, locale) {
2294
- if (!joinContext?.resolveDictSource) return void 0;
2295
- const resolveDict = joinContext.resolveDictSource.bind(joinContext);
2296
- let maps;
2297
- for (const { field, by } of orderBy) {
2298
- if (by !== "label") continue;
2299
- const dictSource = resolveDict(field);
2300
- if (!dictSource) continue;
2301
- const loc = locale ?? dictSource.displayLocale;
2302
- if (loc === void 0) continue;
2303
- const codeToLabel = /* @__PURE__ */ new Map();
2304
- for (const entry of dictSource.snapshot()) {
2305
- const k = entry["key"];
2306
- const labels = entry["labels"];
2307
- const label = labels?.[loc];
2308
- if (typeof k === "string" && typeof label === "string") codeToLabel.set(k, label);
2309
- }
2310
- ;
2311
- (maps ??= /* @__PURE__ */ new Map()).set(field, codeToLabel);
2312
- }
2313
- return maps;
2314
- }
2315
- function compareMoney(a, b, desc) {
2316
- const av = moneyScaledValue(a, desc);
2317
- const bv = moneyScaledValue(b, desc);
2318
- if (av === null) return bv === null ? 0 : 1;
2319
- if (bv === null) return -1;
2320
- return av < bv ? -1 : av > bv ? 1 : 0;
2321
- }
2322
- function readField(record, field) {
2323
- if (record === null || record === void 0) return void 0;
2324
- if (!field.includes(".")) {
2325
- return record[field];
2326
- }
2327
- const segments = field.split(".");
2328
- let cursor = record;
2329
- for (const segment of segments) {
2330
- if (cursor === null || cursor === void 0) return void 0;
2331
- cursor = cursor[segment];
2332
- }
2333
- return cursor;
2334
- }
2335
- function compareValues(a, b) {
2336
- if (a === void 0 || a === null) return b === void 0 || b === null ? 0 : 1;
2337
- if (b === void 0 || b === null) return -1;
2338
- if (typeof a === "number" && typeof b === "number") return a - b;
2339
- if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0;
2340
- if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
2341
- return 0;
2342
- }
2343
- function serializePlan(plan) {
2344
- return {
2345
- clauses: plan.clauses.map(serializeClause),
2346
- orderBy: plan.orderBy,
2347
- limit: plan.limit,
2348
- offset: plan.offset,
2349
- joins: plan.joins
2350
- };
2351
- }
2352
- function serializeClause(clause) {
2353
- if (clause.type === "filter") {
2354
- return { type: "filter", fn: "[function]" };
2355
- }
2356
- if (clause.type === "wherePredicate") {
2357
- return {
2358
- type: "wherePredicate",
2359
- name: clause.name,
2360
- ctx: clause.ctx,
2361
- predicateHash: clause.predicateHash,
2362
- ctxHash: clause.ctxHash,
2363
- fn: "[function]"
2364
- };
2365
- }
2366
- if (clause.type === "group") {
2367
- return {
2368
- type: "group",
2369
- op: clause.op,
2370
- clauses: clause.clauses.map(serializeClause)
2371
- };
2372
- }
2373
- if (clause.type === "crossJoin") {
2374
- return {
2375
- type: "crossJoin",
2376
- target: clause.target,
2377
- as: clause.as,
2378
- on: clause.on ? "[function]" : void 0,
2379
- onPredicateName: clause.onPredicateName,
2380
- maxRows: clause.maxRows
2381
- };
2382
- }
2383
- return clause;
2384
- }
2385
- function canonicalCtxHash(ctx) {
2386
- if (ctx === void 0) return "";
2387
- const canonical = JSON.stringify(ctx, (_key, value) => {
2388
- if (value && typeof value === "object" && !Array.isArray(value)) {
2389
- const sorted = {};
2390
- for (const k of Object.keys(value).sort()) {
2391
- sorted[k] = value[k];
2392
- }
2393
- return sorted;
2394
- }
2395
- return value;
2396
- });
2397
- let h = 5381;
2398
- for (let i = 0; i < canonical.length; i++) {
2399
- h = (h << 5) + h ^ canonical.charCodeAt(i);
2400
- }
2401
- return (h >>> 0).toString(16).padStart(8, "0");
2402
- }
2403
- function buildDictLabelResolver(joinCtx, field) {
2404
- if (!joinCtx?.resolveDictSource) return void 0;
2405
- const dictSource = joinCtx.resolveDictSource(field);
2406
- if (!dictSource) return void 0;
2407
- const snapshot = dictSource.snapshot();
2408
- const displayLocale = dictSource.displayLocale;
2409
- const dictMap = /* @__PURE__ */ new Map();
2410
- for (const entry of snapshot) {
2411
- const k = entry["key"];
2412
- const labels = entry["labels"];
2413
- if (typeof k === "string" && labels && typeof labels === "object") {
2414
- dictMap.set(k, labels);
2415
- }
2416
- }
2417
- return async (key, locale, fallback) => {
2418
- const effLocale = locale || displayLocale;
2419
- if (!effLocale) return void 0;
2420
- const labels = dictMap.get(key);
2421
- if (!labels) return void 0;
2422
- if (labels[effLocale] !== void 0) return labels[effLocale];
2423
- const chain = Array.isArray(fallback) ? fallback : fallback ? [fallback] : [];
2424
- for (const fb of chain) {
2425
- if (fb === "any") {
2426
- const any = Object.values(labels)[0];
2427
- if (any !== void 0) return any;
2428
- } else if (labels[fb] !== void 0) {
2429
- return labels[fb];
2430
- }
2431
- }
2432
- return void 0;
2433
- };
2434
- }
2435
-
2436
- // src/indexing/eager-indexes.ts
2437
- var CollectionIndexes = class {
2438
- indexes = /* @__PURE__ */ new Map();
2439
- /**
2440
- * Declare an index. Subsequent record additions are tracked under it.
2441
- * Calling this twice for the same field is a no-op (idempotent).
2442
- */
2443
- declare(field) {
2444
- if (this.indexes.has(field)) return;
2445
- this.indexes.set(field, { field, buckets: /* @__PURE__ */ new Map() });
2446
- }
2447
- /** True if the given field has a declared index. */
2448
- has(field) {
2449
- return this.indexes.has(field);
2450
- }
2451
- /** All declared field names, in declaration order. */
2452
- fields() {
2453
- return [...this.indexes.keys()];
2454
- }
2455
- /**
2456
- * Build all declared indexes from a snapshot of records.
2457
- * Called once per hydration. O(N × indexes.size).
2458
- */
2459
- build(records) {
2460
- for (const idx of this.indexes.values()) {
2461
- idx.buckets.clear();
2462
- for (const { id, record } of records) {
2463
- addToIndex(idx, id, record);
2464
- }
2465
- }
2466
- }
2467
- /**
2468
- * Insert or update a single record across all indexes.
2469
- * Called by `Collection.put()` after the encrypted write succeeds.
2470
- *
2471
- * If `previousRecord` is provided, the record is removed from any old
2472
- * buckets first — this is the update path. Pass `null` for fresh adds.
2473
- */
2474
- upsert(id, newRecord, previousRecord) {
2475
- if (this.indexes.size === 0) return;
2476
- if (previousRecord !== null) {
2477
- this.remove(id, previousRecord);
2478
- }
2479
- for (const idx of this.indexes.values()) {
2480
- addToIndex(idx, id, newRecord);
2481
- }
2482
- }
2483
- /**
2484
- * Remove a record from all indexes. Called by `Collection.delete()`
2485
- * (and as the first half of `upsert` for the update path).
2486
- */
2487
- remove(id, record) {
2488
- if (this.indexes.size === 0) return;
2489
- for (const idx of this.indexes.values()) {
2490
- removeFromIndex(idx, id, record);
2491
- }
2492
- }
2493
- /** Drop all index data. Called when the collection is invalidated. */
2494
- clear() {
2495
- for (const idx of this.indexes.values()) {
2496
- idx.buckets.clear();
2497
- }
2498
- }
2499
- /**
2500
- * Equality lookup: return the set of record ids whose `field` matches
2501
- * the given value. Returns `null` if no index covers the field — the
2502
- * caller should fall back to a linear scan.
2503
- *
2504
- * The returned Set is a reference to the index's internal storage —
2505
- * callers must NOT mutate it.
2506
- */
2507
- lookupEqual(field, value) {
2508
- const idx = this.indexes.get(field);
2509
- if (!idx) return null;
2510
- const key = stringifyKey(value);
2511
- return idx.buckets.get(key) ?? EMPTY_SET;
2512
- }
2513
- /**
2514
- * Set lookup: return the union of record ids whose `field` matches any
2515
- * of the given values. Returns `null` if no index covers the field.
2516
- */
2517
- lookupIn(field, values) {
2518
- const idx = this.indexes.get(field);
2519
- if (!idx) return null;
2520
- const out = /* @__PURE__ */ new Set();
2521
- for (const value of values) {
2522
- const key = stringifyKey(value);
2523
- const bucket = idx.buckets.get(key);
2524
- if (bucket) {
2525
- for (const id of bucket) out.add(id);
2526
- }
2527
- }
2528
- return out;
2529
- }
2530
- };
2531
- var EMPTY_SET = /* @__PURE__ */ new Set();
2532
- function stringifyKey(value) {
2533
- if (value === null || value === void 0) return "\0NULL\0";
2534
- if (typeof value === "string") return value;
2535
- if (typeof value === "number" || typeof value === "boolean") return String(value);
2536
- if (value instanceof Date) return value.toISOString();
2537
- return "\0OBJECT\0";
2538
- }
2539
- function addToIndex(idx, id, record) {
2540
- const value = readPath(record, idx.field);
2541
- if (value === null || value === void 0) return;
2542
- const key = stringifyKey(value);
2543
- let bucket = idx.buckets.get(key);
2544
- if (!bucket) {
2545
- bucket = /* @__PURE__ */ new Set();
2546
- idx.buckets.set(key, bucket);
2547
- }
2548
- bucket.add(id);
2549
- }
2550
- function removeFromIndex(idx, id, record) {
2551
- const value = readPath(record, idx.field);
2552
- if (value === null || value === void 0) return;
2553
- const key = stringifyKey(value);
2554
- const bucket = idx.buckets.get(key);
2555
- if (!bucket) return;
2556
- bucket.delete(id);
2557
- if (bucket.size === 0) idx.buckets.delete(key);
2558
- }
2559
-
2560
- // src/aggregate/reducers.ts
2561
- function count(opts) {
2562
- const _seed = opts?.seed;
2563
- void _seed;
2564
- return {
2565
- op: "count",
2566
- init: () => 0,
2567
- step: (state) => state + 1,
2568
- remove: (state) => state - 1,
2569
- finalize: (state) => state,
2570
- merge: (a, b) => a + b
2571
- };
2572
- }
2573
- function sum(field, opts) {
2574
- const _seed = opts?.seed;
2575
- void _seed;
2576
- return {
2577
- op: "sum",
2578
- field,
2579
- // Money-only metadata, read by `wrapMoneyReducers`. No effect on a
2580
- // generic numeric sum.
2581
- ...opts?.convertTo !== void 0 ? { convertTo: opts.convertTo } : {},
2582
- ...opts?.fx !== void 0 ? { fx: opts.fx } : {},
2583
- init: () => 0,
2584
- step: (state, record) => state + readNumber(record, field),
2585
- remove: (state, record) => state - readNumber(record, field),
2586
- finalize: (state) => state,
2587
- merge: (a, b) => a + b
2588
- };
2589
- }
2590
- function avg(field, opts) {
2591
- const _seed = opts?.seed;
2592
- void _seed;
2593
- return {
2594
- op: "avg",
2595
- field,
2596
- init: () => ({ sum: 0, count: 0 }),
2597
- step: (state, record) => ({
2598
- sum: state.sum + readNumber(record, field),
2599
- count: state.count + 1
2600
- }),
2601
- remove: (state, record) => ({
2602
- sum: state.sum - readNumber(record, field),
2603
- count: state.count - 1
2604
- }),
2605
- finalize: (state) => state.count === 0 ? null : state.sum / state.count,
2606
- merge: (a, b) => ({ sum: a.sum + b.sum, count: a.count + b.count })
2607
- };
2608
- }
2609
- function pushValue(state, value) {
2610
- return { values: [...state.values, value] };
2611
- }
2612
- function removeValue(state, value) {
2613
- const idx = state.values.indexOf(value);
2614
- if (idx < 0) return state;
2615
- const next = state.values.slice();
2616
- next.splice(idx, 1);
2617
- return { values: next };
2618
- }
2619
- function min(field, opts) {
2620
- const _seed = opts?.seed;
2621
- void _seed;
2622
- return {
2623
- op: "min",
2624
- field,
2625
- init: () => ({ values: [] }),
2626
- step: (state, record) => pushValue(state, readNumber(record, field)),
2627
- remove: (state, record) => removeValue(state, readNumber(record, field)),
2628
- finalize: (state) => {
2629
- if (state.values.length === 0) return null;
2630
- let out = state.values[0];
2631
- for (let i = 1; i < state.values.length; i++) {
2632
- const v = state.values[i];
2633
- if (v < out) out = v;
2634
- }
2635
- return out;
2636
- },
2637
- merge: (a, b) => ({ values: [...a.values, ...b.values] })
2638
- };
2639
- }
2640
- function max(field, opts) {
2641
- const _seed = opts?.seed;
2642
- void _seed;
2643
- return {
2644
- op: "max",
2645
- field,
2646
- init: () => ({ values: [] }),
2647
- step: (state, record) => pushValue(state, readNumber(record, field)),
2648
- remove: (state, record) => removeValue(state, readNumber(record, field)),
2649
- finalize: (state) => {
2650
- if (state.values.length === 0) return null;
2651
- let out = state.values[0];
2652
- for (let i = 1; i < state.values.length; i++) {
2653
- const v = state.values[i];
2654
- if (v > out) out = v;
2655
- }
2656
- return out;
2657
- },
2658
- merge: (a, b) => ({ values: [...a.values, ...b.values] })
2659
- };
2660
- }
2661
- function readNumber(record, field) {
2662
- const value = readPath(record, field);
2663
- if (typeof value === "object" && value !== null && "amount" in value && "currency" in value) {
2664
- throw new Error(
2665
- `aggregate: field "${field}" holds a money value but was not money-aware \u2014 declare it in the collection's moneyFields so sum/min/max stay exact`
2666
- );
2667
- }
2668
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
2669
- }
2670
-
2671
- // src/aggregate/aggregation.ts
2672
- function reduceRecords(records, spec) {
2673
- const state = {};
2674
- for (const key of Object.keys(spec)) {
2675
- state[key] = spec[key].init();
2676
- }
2677
- for (const record of records) {
2678
- for (const key of Object.keys(spec)) {
2679
- state[key] = spec[key].step(state[key], record);
2680
- }
2681
- }
2682
- const result = {};
2683
- for (const key of Object.keys(spec)) {
2684
- result[key] = spec[key].finalize(state[key]);
2685
- }
2686
- return result;
2687
- }
2688
- var LiveAggregationImpl = class {
2689
- constructor(recompute, upstreams) {
2690
- this.recompute = recompute;
2691
- try {
2692
- this.value = recompute();
2693
- this.error = void 0;
2694
- } catch (err) {
2695
- this.value = void 0;
2696
- this.error = err;
2697
- }
2698
- for (const upstream of upstreams) {
2699
- const unsub = upstream.subscribe(() => this.refresh());
2700
- this.unsubscribes.push(unsub);
2701
- }
2702
- }
2703
- recompute;
2704
- value;
2705
- error;
2706
- listeners = /* @__PURE__ */ new Set();
2707
- unsubscribes = [];
2708
- stopped = false;
2709
- refresh() {
2710
- if (this.stopped) return;
2711
- try {
2712
- this.value = this.recompute();
2713
- this.error = void 0;
2714
- } catch (err) {
2715
- this.error = err;
2716
- }
2717
- for (const listener of this.listeners) {
2718
- try {
2719
- listener();
2720
- } catch (err) {
2721
- console.warn("[noy-db] LiveAggregation listener threw:", err);
2722
- }
2723
- }
2724
- }
2725
- subscribe(cb) {
2726
- if (this.stopped) {
2727
- return () => {
2728
- };
2729
- }
2730
- this.listeners.add(cb);
2731
- return () => {
2732
- this.listeners.delete(cb);
2733
- };
2734
- }
2735
- stop() {
2736
- if (this.stopped) return;
2737
- this.stopped = true;
2738
- for (const unsub of this.unsubscribes) {
2739
- try {
2740
- unsub();
2741
- } catch (err) {
2742
- console.warn("[noy-db] LiveAggregation upstream unsubscribe threw:", err);
2743
- }
2744
- }
2745
- this.unsubscribes.length = 0;
2746
- this.listeners.clear();
2747
- }
2748
- };
2749
- var Aggregation = class {
2750
- constructor(executeRecords, spec, upstreams) {
2751
- this.executeRecords = executeRecords;
2752
- this.spec = spec;
2753
- this.upstreams = upstreams;
2754
- }
2755
- executeRecords;
2756
- spec;
2757
- upstreams;
2758
- /**
2759
- * Execute the query and reduce the results synchronously.
2760
- * Returns the reduced shape matching the spec — e.g. a spec of
2761
- * `{ total: sum('amount'), n: count() }` returns
2762
- * `{ total: number, n: number }`.
2763
- */
2764
- run() {
2765
- return reduceRecords(this.executeRecords(), this.spec);
2766
- }
2767
- /**
2768
- * Build a reactive `LiveAggregation<R>` that re-runs the reduction
2769
- * whenever any upstream source notifies of a change. The initial
2770
- * value is computed eagerly in the constructor, so consumers can
2771
- * read `live.value` immediately after calling `.live()`.
2772
- *
2773
- * Always call `live.stop()` when finished — it tears down the
2774
- * upstream subscriptions. Vue's `onUnmounted` is the canonical
2775
- * place.
2776
- *
2777
- * **Implementation note:** every upstream change triggers a full
2778
- * re-reduction. Incremental maintenance (O(1) per delta for
2779
- * sum/count/avg via the reducer protocol's `remove()` method) is a
2780
- * planned follow-up optimization — the protocol already supports
2781
- * it, but the executor doesn't drive it yet. Consumers get
2782
- * correct, reactive values today; future PRs can switch to
2783
- * delta-based maintenance without changing this API.
2784
- */
2785
- live() {
2786
- const recompute = () => reduceRecords(this.executeRecords(), this.spec);
2787
- return new LiveAggregationImpl(recompute, this.upstreams);
2788
- }
2789
- };
2790
- function buildLiveAggregation(recompute, upstreams) {
2791
- return new LiveAggregationImpl(recompute, upstreams);
2792
- }
2793
-
2794
- // src/aggregate/canonical-key.ts
2795
- function canonicalGroupKey(fields, row) {
2796
- const sorted = [...fields].sort();
2797
- const parts = [];
2798
- for (const name of sorted) {
2799
- const v = row[name];
2800
- const serialised = v === void 0 ? "undefined" : JSON.stringify(v);
2801
- parts.push(`${name}=${serialised}`);
2802
- }
2803
- return parts.join("|");
2804
- }
2805
-
2806
- // src/aggregate/groupby.ts
2807
- var GROUPBY_WARN_CARDINALITY = 1e4;
2808
- var GROUPBY_MAX_CARDINALITY = 1e5;
2809
- var warnedCardinalityFields = /* @__PURE__ */ new Set();
2810
- function warnCardinalityApproaching(fields, observed) {
2811
- const key = JSON.stringify([...fields].sort());
2812
- if (warnedCardinalityFields.has(key)) return;
2813
- warnedCardinalityFields.add(key);
2814
- const label = `[${fields.join(", ")}]`;
2815
- console.warn(
2816
- `[noy-db] .groupBy(${label}) 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.`
2817
- );
2818
- }
2819
- function resetGroupByWarnings() {
2820
- warnedCardinalityFields.clear();
2821
- }
2822
- var GroupedQueryBase = class {
2823
- constructor(executeRecords, fieldOrFields, upstreams, dictLabelResolver, moneyFields) {
2824
- this.executeRecords = executeRecords;
2825
- this.upstreams = upstreams;
2826
- this.dictLabelResolver = dictLabelResolver;
2827
- this.moneyFields = moneyFields;
2828
- this.fields = typeof fieldOrFields === "string" ? [fieldOrFields] : [...fieldOrFields];
2829
- }
2830
- executeRecords;
2831
- upstreams;
2832
- dictLabelResolver;
2833
- moneyFields;
2834
- /**
2835
- * Field set this grouped query buckets on. Stored in declaration
2836
- * order — the same order is preserved on every result row by
2837
- * `groupAndReduce`. For the single-field constructor, this is
2838
- * `[field]`.
2839
- */
2840
- fields;
2841
- /** Apply money-aware reducer rewriting when money fields are declared. */
2842
- wrapSpec(spec) {
2843
- return this.moneyFields ? wrapMoneyReducers(spec, this.moneyFields) : spec;
2844
- }
2845
- };
2846
- var GroupedQuery = class extends GroupedQueryBase {
2847
- /**
2848
- * Build a grouped aggregation. Returns a `GroupedAggregation`
2849
- * with `.run()`, `.runAsync()`, and `.live()` terminals — same shape
2850
- * as the non-grouped `.aggregate()` wrapper, just with an array
2851
- * result (one row per bucket) instead of a single reduced object.
2852
- */
2853
- aggregate(spec) {
2854
- return new GroupedAggregation(
2855
- this.executeRecords,
2856
- this.fields,
2857
- this.wrapSpec(spec),
2858
- this.upstreams,
2859
- this.dictLabelResolver
2860
- );
2861
- }
2862
- };
2863
- var GroupedQueryN = class extends GroupedQueryBase {
2864
- aggregate(spec) {
2865
- return new GroupedAggregation(
2866
- this.executeRecords,
2867
- this.fields,
2868
- this.wrapSpec(spec),
2869
- this.upstreams,
2870
- this.dictLabelResolver
2871
- );
2872
- }
2873
- };
2874
- function groupAndReduce(records, fieldOrFields, spec, moneyFields) {
2875
- const fields = typeof fieldOrFields === "string" ? [fieldOrFields] : fieldOrFields;
2876
- if (fields.length === 0) {
2877
- throw new Error(".groupBy() requires at least one field");
2878
- }
2879
- if (moneyFields) {
2880
- spec = wrapMoneyReducers(spec, moneyFields);
2881
- }
2882
- const buckets = /* @__PURE__ */ new Map();
2883
- const fieldLabel = fields.length === 1 ? fields[0] : `[${fields.join(", ")}]`;
2884
- for (const record of records) {
2885
- const keyValues = {};
2886
- for (const f of fields) {
2887
- keyValues[f] = readPath(record, f);
2888
- }
2889
- const dedupKey = canonicalGroupKey(fields, keyValues);
2890
- let bucket = buckets.get(dedupKey);
2891
- if (bucket === void 0) {
2892
- if (buckets.size >= GROUPBY_MAX_CARDINALITY) {
2893
- throw new GroupCardinalityError(
2894
- fieldLabel,
2895
- buckets.size + 1,
2896
- GROUPBY_MAX_CARDINALITY
2897
- );
2898
- }
2899
- bucket = { keyValues, records: [] };
2900
- buckets.set(dedupKey, bucket);
2901
- }
2902
- bucket.records.push(record);
2903
- }
2904
- if (buckets.size >= GROUPBY_WARN_CARDINALITY) {
2905
- warnCardinalityApproaching(fields, buckets.size);
2906
- }
2907
- const reducerKeys = Object.keys(spec);
2908
- const out = [];
2909
- for (const bucket of buckets.values()) {
2910
- const state = {};
2911
- for (const rk of reducerKeys) {
2912
- state[rk] = spec[rk].init();
2913
- }
2914
- for (const record of bucket.records) {
2915
- for (const rk of reducerKeys) {
2916
- state[rk] = spec[rk].step(state[rk], record);
2917
- }
2918
- }
2919
- const row = {};
2920
- for (const f of fields) {
2921
- row[f] = bucket.keyValues[f];
2922
- }
2923
- for (const rk of reducerKeys) {
2924
- row[rk] = spec[rk].finalize(state[rk]);
2925
- }
2926
- out.push(row);
2927
- }
2928
- return out;
2929
- }
2930
- var GroupedAggregation = class {
2931
- constructor(executeRecords, fields, spec, upstreams, dictLabelResolver) {
2932
- this.executeRecords = executeRecords;
2933
- this.spec = spec;
2934
- this.upstreams = upstreams;
2935
- this.dictLabelResolver = dictLabelResolver;
2936
- this.fields = typeof fields === "string" ? [fields] : [...fields];
2937
- }
2938
- executeRecords;
2939
- spec;
2940
- upstreams;
2941
- dictLabelResolver;
2942
- fields;
2943
- /**
2944
- * Execute the query, group, reduce, and return an array of rows.
2945
- *
2946
- * `opts` (#285 query-form MV grouping): when a `locale` + `i18nFields` are
2947
- * given, the declared group-key `i18nText` fields are resolved to that locale
2948
- * at the `mv` layer BEFORE bucketing — so an i18n group key is a stable string
2949
- * instead of a raw `{locale}` map. The MV executor passes the MV's
2950
- * `i18nLocale`/`i18nFields`; ordinary `.run()` callers pass nothing and are
2951
- * unaffected.
2952
- */
2953
- run(opts) {
2954
- let records = this.executeRecords();
2955
- if (opts?.locale !== void 0 && opts.i18nFields !== void 0) {
2956
- const groupI18n = {};
2957
- for (const f of this.fields) {
2958
- const d = opts.i18nFields[f];
2959
- if (d !== void 0) groupI18n[f] = d;
2960
- }
2961
- if (Object.keys(groupI18n).length > 0) {
2962
- records = records.map((r) => applyI18nLocale(r, groupI18n, opts.locale, void 0, "mv"));
2963
- }
2964
- }
2965
- return groupAndReduce(records, this.fields, this.spec);
2966
- }
2967
- /**
2968
- * Execute the query, group, reduce, and resolve `<field>Label` for
2969
- * each result row when the grouping field is a `dictKey` and a
2970
- * `locale` is provided. Returns `R[]` synchronously when
2971
- * no locale is specified (identical to `.run()`).
2972
- *
2973
- * The `<field>Label` field is appended to each row. Rows whose group
2974
- * key has no dictionary entry get `<field>Label: undefined`.
2975
- *
2976
- * Dict-label resolution is single-field only — multi-key groupings
2977
- * do not produce a `<field>Label`. The resolver is only attached
2978
- * by the builder when `fields.length === 1`.
2979
- */
2980
- async runAsync(opts) {
2981
- const rows = groupAndReduce(this.executeRecords(), this.fields, this.spec);
2982
- if (!opts?.locale || !this.dictLabelResolver || this.fields.length !== 1) return rows;
2983
- const resolve = this.dictLabelResolver;
2984
- const locale = opts.locale;
2985
- const fallback = opts.fallback;
2986
- const field = this.fields[0];
2987
- const labelKey = `${field}Label`;
2988
- return Promise.all(
2989
- rows.map(async (row) => {
2990
- const key = row[field];
2991
- if (typeof key !== "string") return row;
2992
- const label = await resolve(key, locale, fallback);
2993
- return { ...row, [labelKey]: label };
2994
- })
2995
- );
2996
- }
2997
- /**
2998
- * Build a reactive `LiveAggregation<R[]>` that re-runs the full
2999
- * group-and-reduce pipeline whenever any upstream source notifies
3000
- * of a change. Same error-isolation and idempotent-stop contract
3001
- * as `Aggregation.live()` — the implementation delegates to the
3002
- * same `LiveAggregationImpl` class by threading a fresh
3003
- * recompute closure through the existing constructor.
3004
- *
3005
- * uses naive full re-run on every change. Incremental
3006
- * per-bucket maintenance (apply `step` on inserted records,
3007
- * `remove` on deleted records, route by bucket key) is a future
3008
- * optimization — the reducer protocol admits it, but wiring
3009
- * delta-aware source subscriptions is a separate PR.
3010
- *
3011
- * Always call `live.stop()` when finished.
3012
- */
3013
- live() {
3014
- const recompute = () => groupAndReduce(this.executeRecords(), this.fields, this.spec);
3015
- return buildLiveAggregation(recompute, this.upstreams);
3016
- }
3017
- };
3018
-
3019
- // src/query/scan-builder.ts
3020
- var DEFAULT_SCAN_PAGE_SIZE = 100;
3021
- var ScanBuilder = class _ScanBuilder {
3022
- pageProvider;
3023
- pageSize;
3024
- clauses;
3025
- /**
3026
- * Zero-or-more join legs to apply per record as the stream flows.
3027
- * Each leg attaches the resolved right-side record (or null) under
3028
- * its alias. — streaming joins.
3029
- *
3030
- * Joins are evaluated AFTER clauses, so a `where()` filtered-out
3031
- * record never triggers a right-side lookup. This is the same
3032
- * ordering as `Query.toArray()` (clauses first, joins after) and
3033
- * keeps the streaming path from doing wasted work.
3034
- */
3035
- joins;
3036
- /**
3037
- * Join resolution context. Required for `.join()` to translate a
3038
- * field name into a target collection + ref mode and to resolve
3039
- * the right-side `JoinableSource`. Optional because tests
3040
- * construct ScanBuilder directly with synthetic page providers
3041
- * that don't know about ref() — calling `.join()` without a
3042
- * context throws with an actionable error.
3043
- */
3044
- joinContext;
3045
- /**
3046
- * Money field descriptors for the backing collection. When present, yielded
3047
- * records are decoded (stored scaled-int → canonical decimal) so `scan()`
3048
- * agrees with `get()`/`list()`/`query().toArray()` — #322. Decoded with
3049
- * `'raw'` (canonical decimal, no locale-formatted virtuals) since the scan
3050
- * stream carries no locale context, mirroring `Query.toArray()`.
3051
- */
3052
- moneyFields;
3053
- constructor(pageProvider, pageSize = DEFAULT_SCAN_PAGE_SIZE, clauses = [], joins = [], joinContext, moneyFields) {
3054
- this.pageProvider = pageProvider;
3055
- this.pageSize = pageSize;
3056
- this.clauses = clauses;
3057
- this.joins = joins;
3058
- this.joinContext = joinContext;
3059
- this.moneyFields = moneyFields;
3060
- }
3061
- /**
3062
- * Decode this scan's money fields on a record (stored scaled-int → canonical
3063
- * decimal). No-op when no money fields are declared. See {@link moneyFields}.
3064
- */
3065
- decodeMoney(record) {
3066
- if (!this.moneyFields || Object.keys(this.moneyFields).length === 0) return record;
3067
- return decodeMoneyFields(record, this.moneyFields, "raw");
3068
- }
3069
- /**
3070
- * Add a field comparison. Runs per record as the scan stream
3071
- * flows through, so non-matching records are dropped before they
3072
- * reach `.aggregate()` or the iteration consumer. Multiple
3073
- * `.where()` calls are AND-combined — same semantics as
3074
- * `Query.where()`.
3075
- *
3076
- * Clauses cannot use the secondary-index fast path here because
3077
- * the scan sources records from the adapter's paginator, not from
3078
- * the in-memory cache where indexes live. Index-accelerated scans
3079
- * are a future optimization — the current implementation
3080
- * evaluates clauses per record in O(1) per clause.
3081
- */
3082
- where(field, op, value) {
3083
- const desc = this.moneyFields?.[field];
3084
- const clause = desc ? moneyFieldClause(field, op, value, desc) : { type: "field", field, op, value };
3085
- return new _ScanBuilder(
3086
- this.pageProvider,
3087
- this.pageSize,
3088
- [...this.clauses, clause],
3089
- this.joins,
3090
- this.joinContext,
3091
- this.moneyFields
3092
- );
3093
- }
3094
- /**
3095
- * Escape hatch: add an arbitrary predicate function. Same
3096
- * non-serializable caveat as `Query.filter()` — filter clauses
3097
- * don't round-trip through `toPlan()`. Prefer `.where()` when
3098
- * possible.
3099
- */
3100
- filter(fn) {
3101
- const clause = {
3102
- type: "filter",
3103
- fn
3104
- };
3105
- return new _ScanBuilder(
3106
- this.pageProvider,
3107
- this.pageSize,
3108
- [...this.clauses, clause],
3109
- this.joins,
3110
- this.joinContext,
3111
- this.moneyFields
3112
- );
3113
- }
3114
- /**
3115
- * Resolve a `ref()`-declared foreign key per record as the scan
3116
- * stream flows, attaching the right-side record (or null) under
3117
- * `opts.as`. — streaming joins over `scan()`.
3118
- *
3119
- * ```ts
3120
- * for await (const inv of invoices.scan().join('clientId', { as: 'client' })) {
3121
- * await processInvoice(inv) // inv.client is attached
3122
- * }
3123
- *
3124
- * // Or terminate with .aggregate() for streaming joined aggregation
3125
- * const { total } = await invoices.scan()
3126
- * .where('status', '==', 'open')
3127
- * .join('clientId', { as: 'client' })
3128
- * .aggregate({ total: sum('amount') })
3129
- * ```
3130
- *
3131
- * **The key difference from eager `.join()`:** the LEFT
3132
- * side streams page-by-page from the adapter and is never
3133
- * materialized. Memory ceiling on the left is O(pageSize), not
3134
- * O(rowCount). This is what makes streaming joins suitable for
3135
- * collections that exceed the eager join's 50_000-row ceiling.
3136
- *
3137
- * **Right-side strategy** is auto-selected per leg:
3138
- * - **Indexed** — right source exposes `lookupById`, so each
3139
- * left row costs O(1). This is the common path for
3140
- * Collection right sides, which back `lookupById` with a Map
3141
- * lookup over the in-memory cache. The right collection must
3142
- * be in eager mode (the same constraint as eager join's
3143
- * `querySourceForJoin` from ).
3144
- * - **Hash** — right source has only `snapshot()`. Build a
3145
- * `Map<id, record>` once at iteration start, probe per left
3146
- * row. Same correctness, same per-row cost as the indexed
3147
- * path; the difference is the upfront cost of materializing
3148
- * the right side once.
3149
- *
3150
- * Both strategies hold the right side in memory for the duration
3151
- * of the iteration. The "streaming" property applies to the LEFT
3152
- * side only — true left-and-right streaming joins (where neither
3153
- * side fits in memory) require a sort-merge join planner that's
3154
- * out of scope for.
3155
- *
3156
- * **Ref-mode semantics** match eager `.join()` exactly:
3157
- * - `strict` → throws `DanglingReferenceError` mid-stream
3158
- * when a left record points at a non-existent right id.
3159
- * The throw aborts the async iterator — consumers should
3160
- * wrap the `for await` in try/catch if they want to recover.
3161
- * - `warn` → attaches `null` and emits a one-shot warning
3162
- * per unique dangling pair (deduped via the same warn
3163
- * channel as eager join).
3164
- * - `cascade` → attaches `null` silently. A delete-time mode;
3165
- * dangling refs at read time are mid-flight or pre-existing
3166
- * orphans, not a DSL error.
3167
- *
3168
- * Left records with null/undefined FK values attach `null`
3169
- * regardless of mode — same "no reference at all" policy as
3170
- * eager join and write-time `enforceRefsOnPut`.
3171
- *
3172
- * **Multi-FK chaining** is supported via repeated `.join()`
3173
- * calls: each leg resolves an independent ref. Each leg
3174
- * independently picks its right-side strategy and applies its
3175
- * own ref mode.
3176
- *
3177
- * **Joins are NOT applied** to a `.aggregate()` terminal that
3178
- * doesn't reference joined fields — wait, that's not quite
3179
- * right. The streaming path actually DOES apply joins before
3180
- * `.aggregate()` because the join attaches a field that the
3181
- * spec might reference. Unlike `Query.aggregate()` (which skips
3182
- * joins entirely as a projection-only short-circuit), the
3183
- * streaming aggregation can't know whether the spec touches a
3184
- * joined field, so it always applies joins. Consumers who want
3185
- * unjoined streaming aggregation should leave `.join()` off the
3186
- * chain — the chain is composable for a reason.
3187
- *
3188
- * constraint #1 — every JoinLeg carries `partitionScope:
3189
- * 'all'` plumbed through but never read by. Same seam as
3190
- * eager join.
3191
- */
3192
- join(field, opts) {
3193
- if (!this.joinContext) {
3194
- throw new Error(
3195
- `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).`
3196
- );
3197
- }
3198
- const descriptor = this.joinContext.resolveRef(field);
3199
- if (!descriptor) {
3200
- throw new Error(
3201
- `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.`
3202
- );
3203
- }
3204
- const leg = {
3205
- field,
3206
- as: opts.as,
3207
- target: descriptor.target,
3208
- mode: descriptor.mode,
3209
- strategy: void 0,
3210
- maxRows: void 0,
3211
- // constraint #1 — always 'all' in, never read by
3212
- // the streaming executor. partition-aware scan joins
3213
- // will populate this from where() predicates without
3214
- // changing the planner shape.
3215
- partitionScope: "all"
3216
- };
3217
- return new _ScanBuilder(
3218
- this.pageProvider,
3219
- this.pageSize,
3220
- this.clauses,
3221
- [...this.joins, leg],
3222
- this.joinContext,
3223
- this.moneyFields
3224
- );
3225
- }
3226
- /**
3227
- * Iterate the scan as an async iterable. Walks the page
3228
- * provider's cursors forward until exhaustion, applying every
3229
- * clause per record — only matching records are yielded.
3230
- *
3231
- * Backward-compatible with the previous async-generator `scan()`
3232
- * return type for `for await … of` consumers.
3233
- */
3234
- async *[Symbol.asyncIterator]() {
3235
- const joinResolvers = this.joins.length === 0 ? null : this.buildJoinResolvers();
3236
- let page = await this.pageProvider.listPage({ limit: this.pageSize });
3237
- while (true) {
3238
- for (const record of page.items) {
3239
- if (!this.recordMatches(record)) continue;
3240
- const decoded = this.decodeMoney(record);
3241
- if (joinResolvers === null) {
3242
- yield decoded;
3243
- } else {
3244
- let attached = decoded;
3245
- for (const resolver of joinResolvers) {
3246
- attached = this.applyOneJoinStreaming(attached, resolver);
3247
- }
3248
- yield attached;
3249
- }
3250
- }
3251
- if (page.nextCursor === null) return;
3252
- page = await this.pageProvider.listPage({
3253
- cursor: page.nextCursor,
3254
- limit: this.pageSize
3255
- });
3256
- }
3257
- }
3258
- /**
3259
- * Per-leg right-side resolution state. Built once at iteration
3260
- * start and reused for every left record. Two strategies:
3261
- *
3262
- * - `lookupById`: present when the right source exposes the
3263
- * hook directly (typical Collection right side). Per-row
3264
- * cost is O(1).
3265
- * - `hashByPrimaryKey`: built from `snapshot()` when no
3266
- * lookupById. Per-row cost is O(1) after the upfront O(N)
3267
- * materialization. Same as eager join's hash strategy.
3268
- *
3269
- * `warnedKeys` is the per-leg dedup set for ref-mode 'warn'. We
3270
- * key on `field→target:refId` so the same dangling pair only
3271
- * warns once per iteration. The dedup is per-iteration, not
3272
- * per-process — a long-running scan that re-iterates would warn
3273
- * again, which is the desired behavior (the data may have
3274
- * changed between iterations).
3275
- */
3276
- buildJoinResolvers() {
3277
- if (!this.joinContext) {
3278
- throw new Error(
3279
- `ScanBuilder iterator: ${this.joins.length} join leg(s) present but no JoinContext attached. Use collection.scan() to construct a join-capable scan.`
3280
- );
3281
- }
3282
- const resolvers = [];
3283
- for (const leg of this.joins) {
3284
- const source = this.joinContext.resolveSource(leg.target);
3285
- if (!source) {
3286
- throw new Error(
3287
- `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.`
3288
- );
3289
- }
3290
- let lookupById = null;
3291
- let hashByPrimaryKey = null;
3292
- if (source.lookupById) {
3293
- const fn = source.lookupById.bind(source);
3294
- lookupById = (id) => fn(id);
3295
- } else {
3296
- const map = /* @__PURE__ */ new Map();
3297
- for (const record of source.snapshot()) {
3298
- const rawId = readPath(record, "id");
3299
- const key = coerceRefKey2(rawId);
3300
- if (key !== null) map.set(key, record);
3301
- }
3302
- hashByPrimaryKey = map;
3303
- }
3304
- resolvers.push({
3305
- leg,
3306
- source,
3307
- lookupById,
3308
- hashByPrimaryKey,
3309
- warnedKeys: /* @__PURE__ */ new Set()
3310
- });
3311
- }
3312
- return resolvers;
3313
- }
3314
- /**
3315
- * Resolve a single join leg for one left record and return the
3316
- * left record with the joined field attached under
3317
- * `leg.as`. Pure function over `(left, resolver)`; never
3318
- * mutates the input.
3319
- *
3320
- * Ref-mode dispatch matches eager `applyJoins` from :
3321
- * - null/undefined FK → attach null silently (always allowed)
3322
- * - dangling FK + strict → throw `DanglingReferenceError`
3323
- * - dangling FK + warn → attach null, warn-once per pair
3324
- * - dangling FK + cascade → attach null silently
3325
- */
3326
- applyOneJoinStreaming(left, resolver) {
3327
- if (left === null || typeof left !== "object") {
3328
- return left;
3329
- }
3330
- const { leg } = resolver;
3331
- const rawId = readPath(left, leg.field);
3332
- const refKey = coerceRefKey2(rawId);
3333
- let right = void 0;
3334
- if (refKey !== null) {
3335
- if (resolver.lookupById !== null) {
3336
- right = resolver.lookupById(refKey);
3337
- } else if (resolver.hashByPrimaryKey !== null) {
3338
- right = resolver.hashByPrimaryKey.get(refKey);
3339
- }
3340
- }
3341
- const merged = {
3342
- ...left
3343
- };
3344
- if (right === void 0) {
3345
- if (refKey !== null && leg.mode === "strict") {
3346
- throw new DanglingReferenceError({
3347
- field: leg.field,
3348
- target: leg.target,
3349
- refId: refKey,
3350
- 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.`
3351
- });
3352
- }
3353
- if (refKey !== null && leg.mode === "warn") {
3354
- const dedupKey = `${leg.field}\u2192${leg.target}:${refKey}`;
3355
- if (!resolver.warnedKeys.has(dedupKey)) {
3356
- resolver.warnedKeys.add(dedupKey);
3357
- console.warn(
3358
- `[noy-db] ScanBuilder.join() encountered dangling ref in 'warn' mode: field "${leg.field}" \u2192 "${leg.target}:${refKey}" not found. Attaching null.`
3359
- );
3360
- }
3361
- }
3362
- merged[leg.as] = null;
3363
- } else {
3364
- merged[leg.as] = right;
3365
- }
3366
- return merged;
3367
- }
3368
- /**
3369
- * Reduce the scan stream through a named set of reducers and
3370
- * return the final aggregated shape.
3371
- *
3372
- * Memory is O(reducers): one mutable state slot per spec key.
3373
- * Records flow through the pipeline one at a time via
3374
- * `for await` and are discarded after their `step()` is applied
3375
- * — never collected into an array. This is the distinguishing
3376
- * property from `Query.aggregate()`, which materializes the full
3377
- * match set first.
3378
- *
3379
- * Reuses the same reducer protocol as `Query.aggregate()`,
3380
- * so `count()`, `sum(field)`, `avg(field)`, `min(field)`,
3381
- * `max(field)` all work unchanged. The `{ seed }` parameter
3382
- * plumbing from constraint #2 is honored transparently — the
3383
- * factories ignore it in and the scan executor never
3384
- * touches the per-reducer state construction.
3385
- *
3386
- * **Returns a Promise**, unlike `Query.aggregate().run()` which
3387
- * is synchronous. The scan is inherently async because it walks
3388
- * adapter pages, so the terminal has to be too. Consumers
3389
- * destructure with await:
3390
- *
3391
- * ```ts
3392
- * const { total, n } = await invoices.scan()
3393
- * .where('year', '==', 2025)
3394
- * .aggregate({ total: sum('amount'), n: count() })
3395
- * ```
3396
- *
3397
- * **No `.live()` in.** `scan().aggregate().live()` would
3398
- * require reconciling an unbounded streaming iteration with a
3399
- * change-stream subscription — a design problem, not just a code
3400
- * one. Consumers with huge collections and live needs should
3401
- * narrow with `.where()` enough to fit in the 50k `query()`
3402
- * limit and use `query().aggregate().live()` instead.
3403
- */
3404
- async aggregate(spec) {
3405
- const keys = Object.keys(spec);
3406
- const state = {};
3407
- for (const key of keys) {
3408
- state[key] = spec[key].init();
3409
- }
3410
- for await (const record of this) {
3411
- for (const key of keys) {
3412
- state[key] = spec[key].step(state[key], record);
3413
- }
3414
- }
3415
- const result = {};
3416
- for (const key of keys) {
3417
- result[key] = spec[key].finalize(state[key]);
3418
- }
3419
- return result;
3420
- }
3421
- /**
3422
- * Evaluate the clause list against a single record. Linear in
3423
- * the clause count; short-circuits on first false. Clauses on a
3424
- * scan are always re-evaluated per record — no index-accelerated
3425
- * path, because the stream sources records from the adapter
3426
- * paginator, not from the in-memory cache where indexes live.
3427
- */
3428
- recordMatches(record) {
3429
- if (this.clauses.length === 0) return true;
3430
- const fnView = this.moneyFields && Object.keys(this.moneyFields).length > 0 && hasFnClause(this.clauses) ? this.decodeMoney(record) : void 0;
3431
- for (const clause of this.clauses) {
3432
- if (!evaluateClause(record, clause, fnView)) return false;
3433
- }
3434
- return true;
3435
- }
3436
- };
3437
- function coerceRefKey2(value) {
3438
- if (value === null || value === void 0) return null;
3439
- if (typeof value === "string") return value;
3440
- if (typeof value === "number" || typeof value === "bigint") return String(value);
3441
- return null;
3442
- }
3443
- // Annotate the CommonJS export names for ESM import in node:
3444
- 0 && (module.exports = {
3445
- Aggregation,
3446
- CollectionIndexes,
3447
- CrossJoinSourceUnknownError,
3448
- CrossJoinTooLargeError,
3449
- DEFAULT_CROSS_JOIN_MAX_ROWS,
3450
- DEFAULT_JOIN_MAX_ROWS,
3451
- DanglingReferenceError,
3452
- GROUPBY_MAX_CARDINALITY,
3453
- GROUPBY_WARN_CARDINALITY,
3454
- GroupCardinalityError,
3455
- GroupedAggregation,
3456
- GroupedQuery,
3457
- GroupedQueryN,
3458
- IndexRequiredError,
3459
- IndexWriteFailureError,
3460
- JoinTooLargeError,
3461
- Query,
3462
- ScanBuilder,
3463
- applyJoins,
3464
- avg,
3465
- buildLiveAggregation,
3466
- buildLiveQuery,
3467
- count,
3468
- evaluateClause,
3469
- evaluateFieldClause,
3470
- executePlan,
3471
- groupAndReduce,
3472
- max,
3473
- min,
3474
- readPath,
3475
- reduceRecords,
3476
- resetGroupByWarnings,
3477
- resetJoinWarnings,
3478
- sum
3479
- });
3480
- //# sourceMappingURL=index.cjs.map