@noy-db/hub 0.1.0-pre.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/aggregate/index.cjs +476 -0
- package/dist/aggregate/index.cjs.map +1 -0
- package/dist/aggregate/index.d.cts +38 -0
- package/dist/aggregate/index.d.ts +38 -0
- package/dist/aggregate/index.js +53 -0
- package/dist/aggregate/index.js.map +1 -0
- package/dist/blobs/index.cjs +1480 -0
- package/dist/blobs/index.cjs.map +1 -0
- package/dist/blobs/index.d.cts +45 -0
- package/dist/blobs/index.d.ts +45 -0
- package/dist/blobs/index.js +48 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/bundle/index.cjs +496 -0
- package/dist/bundle/index.cjs.map +1 -0
- package/dist/bundle/index.d.cts +7 -0
- package/dist/bundle/index.d.ts +7 -0
- package/dist/bundle/index.js +51 -0
- package/dist/bundle/index.js.map +1 -0
- package/dist/chunk-2QR2PQTT.js +217 -0
- package/dist/chunk-2QR2PQTT.js.map +1 -0
- package/dist/chunk-72UIIX3E.js +1109 -0
- package/dist/chunk-72UIIX3E.js.map +1 -0
- package/dist/chunk-A4NFZKRW.js +722 -0
- package/dist/chunk-A4NFZKRW.js.map +1 -0
- package/dist/chunk-AOYCZP2H.js +793 -0
- package/dist/chunk-AOYCZP2H.js.map +1 -0
- package/dist/chunk-CIMZBAZB.js +72 -0
- package/dist/chunk-CIMZBAZB.js.map +1 -0
- package/dist/chunk-E3AGCGJ4.js +160 -0
- package/dist/chunk-E3AGCGJ4.js.map +1 -0
- package/dist/chunk-EKX3YVCI.js +97 -0
- package/dist/chunk-EKX3YVCI.js.map +1 -0
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/chunk-EMMRIE3C.js +72 -0
- package/dist/chunk-EMMRIE3C.js.map +1 -0
- package/dist/chunk-EUNIORPU.js +680 -0
- package/dist/chunk-EUNIORPU.js.map +1 -0
- package/dist/chunk-FZU343FL.js +32 -0
- package/dist/chunk-FZU343FL.js.map +1 -0
- package/dist/chunk-GHGXG53C.js +795 -0
- package/dist/chunk-GHGXG53C.js.map +1 -0
- package/dist/chunk-GKA4BGJN.js +79 -0
- package/dist/chunk-GKA4BGJN.js.map +1 -0
- package/dist/chunk-HG2OWBLX.js +430 -0
- package/dist/chunk-HG2OWBLX.js.map +1 -0
- package/dist/chunk-IGAROPKM.js +34 -0
- package/dist/chunk-IGAROPKM.js.map +1 -0
- package/dist/chunk-J66GRPNH.js +111 -0
- package/dist/chunk-J66GRPNH.js.map +1 -0
- package/dist/chunk-LVMMDXFT.js +275 -0
- package/dist/chunk-LVMMDXFT.js.map +1 -0
- package/dist/chunk-M5INGEFC.js +84 -0
- package/dist/chunk-M5INGEFC.js.map +1 -0
- package/dist/chunk-NBYQNDXA.js +557 -0
- package/dist/chunk-NBYQNDXA.js.map +1 -0
- package/dist/chunk-NPC4LFV5.js +132 -0
- package/dist/chunk-NPC4LFV5.js.map +1 -0
- package/dist/chunk-NSWHB5VQ.js +1285 -0
- package/dist/chunk-NSWHB5VQ.js.map +1 -0
- package/dist/chunk-OLM4LA6K.js +392 -0
- package/dist/chunk-OLM4LA6K.js.map +1 -0
- package/dist/chunk-UAFBZWFB.js +155 -0
- package/dist/chunk-UAFBZWFB.js.map +1 -0
- package/dist/chunk-UF3BUNQZ.js +1 -0
- package/dist/chunk-UF3BUNQZ.js.map +1 -0
- package/dist/chunk-UMMAVAYW.js +17 -0
- package/dist/chunk-UMMAVAYW.js.map +1 -0
- package/dist/chunk-UPY7WLBH.js +381 -0
- package/dist/chunk-UPY7WLBH.js.map +1 -0
- package/dist/chunk-W63BWEJH.js +311 -0
- package/dist/chunk-W63BWEJH.js.map +1 -0
- package/dist/chunk-WIGI5OJK.js +90 -0
- package/dist/chunk-WIGI5OJK.js.map +1 -0
- package/dist/chunk-XNL2TKKR.js +490 -0
- package/dist/chunk-XNL2TKKR.js.map +1 -0
- package/dist/chunk-XWNUJPIS.js +367 -0
- package/dist/chunk-XWNUJPIS.js.map +1 -0
- package/dist/chunk-YWKJZZGV.js +715 -0
- package/dist/chunk-YWKJZZGV.js.map +1 -0
- package/dist/consent/index.cjs +204 -0
- package/dist/consent/index.cjs.map +1 -0
- package/dist/consent/index.d.cts +24 -0
- package/dist/consent/index.d.ts +24 -0
- package/dist/consent/index.js +23 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/crdt/index.cjs +152 -0
- package/dist/crdt/index.cjs.map +1 -0
- package/dist/crdt/index.d.cts +30 -0
- package/dist/crdt/index.d.ts +30 -0
- package/dist/crdt/index.js +24 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/crypto-6PNIHP7W.js +44 -0
- package/dist/crypto-6PNIHP7W.js.map +1 -0
- package/dist/delegation-WVIVMF73.js +17 -0
- package/dist/delegation-WVIVMF73.js.map +1 -0
- package/dist/dev-unlock-D4xB0_gs.d.cts +263 -0
- package/dist/dev-unlock-Dz8GEbd3.d.ts +263 -0
- package/dist/hash--EflSV65.d.cts +63 -0
- package/dist/hash-CRdXYnv3.d.ts +63 -0
- package/dist/history/index.cjs +1215 -0
- package/dist/history/index.cjs.map +1 -0
- package/dist/history/index.d.cts +62 -0
- package/dist/history/index.d.ts +62 -0
- package/dist/history/index.js +79 -0
- package/dist/history/index.js.map +1 -0
- package/dist/i18n/index.cjs +840 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +38 -0
- package/dist/i18n/index.d.ts +38 -0
- package/dist/i18n/index.js +68 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index-CD1VnONm.d.cts +415 -0
- package/dist/index-CLRxPs-W.d.cts +1960 -0
- package/dist/index-CUi9wfss.d.ts +415 -0
- package/dist/index-DtV93TMP.d.ts +1960 -0
- package/dist/index.cjs +17387 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +565 -0
- package/dist/index.d.ts +565 -0
- package/dist/index.js +7525 -0
- package/dist/index.js.map +1 -0
- package/dist/indexing/index.cjs +736 -0
- package/dist/indexing/index.cjs.map +1 -0
- package/dist/indexing/index.d.cts +36 -0
- package/dist/indexing/index.d.ts +36 -0
- package/dist/indexing/index.js +77 -0
- package/dist/indexing/index.js.map +1 -0
- package/dist/lazy-builder-BwEoBQZ9.d.ts +304 -0
- package/dist/lazy-builder-CZVLKh0Z.d.cts +304 -0
- package/dist/ledger-HBBH2NPZ.js +33 -0
- package/dist/ledger-HBBH2NPZ.js.map +1 -0
- package/dist/mime-magic-CBBSOkjm.d.cts +50 -0
- package/dist/mime-magic-CBBSOkjm.d.ts +50 -0
- package/dist/periods/index.cjs +1035 -0
- package/dist/periods/index.cjs.map +1 -0
- package/dist/periods/index.d.cts +21 -0
- package/dist/periods/index.d.ts +21 -0
- package/dist/periods/index.js +25 -0
- package/dist/periods/index.js.map +1 -0
- package/dist/predicate-SBHmi6D0.d.cts +161 -0
- package/dist/predicate-SBHmi6D0.d.ts +161 -0
- package/dist/public-envelope-TLQA6REO.js +31 -0
- package/dist/public-envelope-TLQA6REO.js.map +1 -0
- package/dist/query/index.cjs +1999 -0
- package/dist/query/index.cjs.map +1 -0
- package/dist/query/index.d.cts +3 -0
- package/dist/query/index.d.ts +3 -0
- package/dist/query/index.js +73 -0
- package/dist/query/index.js.map +1 -0
- package/dist/session/index.cjs +495 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +45 -0
- package/dist/session/index.d.ts +45 -0
- package/dist/session/index.js +51 -0
- package/dist/session/index.js.map +1 -0
- package/dist/shadow/index.cjs +133 -0
- package/dist/shadow/index.cjs.map +1 -0
- package/dist/shadow/index.d.cts +16 -0
- package/dist/shadow/index.d.ts +16 -0
- package/dist/shadow/index.js +20 -0
- package/dist/shadow/index.js.map +1 -0
- package/dist/store/index.cjs +1083 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.cts +491 -0
- package/dist/store/index.d.ts +491 -0
- package/dist/store/index.js +37 -0
- package/dist/store/index.js.map +1 -0
- package/dist/strategy-BSxFXGzb.d.cts +110 -0
- package/dist/strategy-BSxFXGzb.d.ts +110 -0
- package/dist/strategy-D-SrOLCl.d.cts +548 -0
- package/dist/strategy-D-SrOLCl.d.ts +548 -0
- package/dist/sync/index.cjs +1062 -0
- package/dist/sync/index.cjs.map +1 -0
- package/dist/sync/index.d.cts +42 -0
- package/dist/sync/index.d.ts +42 -0
- package/dist/sync/index.js +28 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/team/index.cjs +2606 -0
- package/dist/team/index.cjs.map +1 -0
- package/dist/team/index.d.cts +117 -0
- package/dist/team/index.d.ts +117 -0
- package/dist/team/index.js +106 -0
- package/dist/team/index.js.map +1 -0
- package/dist/tx/index.cjs +212 -0
- package/dist/tx/index.cjs.map +1 -0
- package/dist/tx/index.d.cts +20 -0
- package/dist/tx/index.d.ts +20 -0
- package/dist/tx/index.js +20 -0
- package/dist/tx/index.js.map +1 -0
- package/dist/types-DSFLtbKg.d.ts +9702 -0
- package/dist/types-zwwMOqkg.d.cts +9702 -0
- package/dist/ulid-COREQ2RQ.js +9 -0
- package/dist/ulid-COREQ2RQ.js.map +1 -0
- package/dist/util/index.cjs +230 -0
- package/dist/util/index.cjs.map +1 -0
- package/dist/util/index.d.cts +77 -0
- package/dist/util/index.d.ts +77 -0
- package/dist/util/index.js +190 -0
- package/dist/util/index.js.map +1 -0
- package/package.json +244 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
evaluateClause,
|
|
3
|
+
readPath
|
|
4
|
+
} from "./chunk-M5INGEFC.js";
|
|
5
|
+
import {
|
|
6
|
+
IndexRequiredError
|
|
7
|
+
} from "./chunk-NBYQNDXA.js";
|
|
8
|
+
|
|
9
|
+
// src/indexing/persisted-indexes.ts
|
|
10
|
+
var IDX_PREFIX = "_idx/";
|
|
11
|
+
function encodeIdxId(field, recordId) {
|
|
12
|
+
return `${IDX_PREFIX}${field}/${recordId}`;
|
|
13
|
+
}
|
|
14
|
+
function decodeIdxId(id) {
|
|
15
|
+
if (!id.startsWith(IDX_PREFIX)) return null;
|
|
16
|
+
const rest = id.slice(IDX_PREFIX.length);
|
|
17
|
+
const firstSlash = rest.indexOf("/");
|
|
18
|
+
if (firstSlash <= 0) return null;
|
|
19
|
+
const field = rest.slice(0, firstSlash);
|
|
20
|
+
const recordId = rest.slice(firstSlash + 1);
|
|
21
|
+
if (recordId.length === 0) return null;
|
|
22
|
+
return { field, recordId };
|
|
23
|
+
}
|
|
24
|
+
function isIdxId(id) {
|
|
25
|
+
return decodeIdxId(id) !== null;
|
|
26
|
+
}
|
|
27
|
+
var COMPOSITE_DELIMITER = "|";
|
|
28
|
+
function compositeKey(fields) {
|
|
29
|
+
return fields.join(COMPOSITE_DELIMITER);
|
|
30
|
+
}
|
|
31
|
+
var PersistedCollectionIndex = class {
|
|
32
|
+
indexes = /* @__PURE__ */ new Map();
|
|
33
|
+
defs = /* @__PURE__ */ new Map();
|
|
34
|
+
/**
|
|
35
|
+
* Declare a single-field index. Subsequent `upsert` / `ingest` calls
|
|
36
|
+
* populate the in-memory mirror; calls before `declare` are no-ops
|
|
37
|
+
* (tolerant bulk-load ordering). Idempotent.
|
|
38
|
+
*/
|
|
39
|
+
declare(field) {
|
|
40
|
+
if (this.indexes.has(field)) return;
|
|
41
|
+
this.indexes.set(field, { buckets: /* @__PURE__ */ new Map(), values: /* @__PURE__ */ new Map() });
|
|
42
|
+
this.defs.set(field, { kind: "single", field, key: field });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Declare a composite (multi-field) index. The synthetic
|
|
46
|
+
* key is `fields.join('|')`; it doubles as the in-memory map key and
|
|
47
|
+
* the `_idx/<key>/<recordId>` side-car field segment. Callers upsert
|
|
48
|
+
* and lookup via the same `key` as single-field indexes, just with a
|
|
49
|
+
* tuple value (JSON-stringified for bucketing).
|
|
50
|
+
*/
|
|
51
|
+
declareComposite(fields) {
|
|
52
|
+
if (fields.length === 0) {
|
|
53
|
+
throw new Error("declareComposite: fields array must be non-empty");
|
|
54
|
+
}
|
|
55
|
+
for (const f of fields) {
|
|
56
|
+
if (f.includes(COMPOSITE_DELIMITER)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`declareComposite: field "${f}" contains the composite delimiter "${COMPOSITE_DELIMITER}" \u2014 pick a different field name or open an issue to add hash-based composite keys.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const key = compositeKey(fields);
|
|
63
|
+
if (this.indexes.has(key)) return;
|
|
64
|
+
this.indexes.set(key, { buckets: /* @__PURE__ */ new Map(), values: /* @__PURE__ */ new Map() });
|
|
65
|
+
this.defs.set(key, { kind: "composite", fields: [...fields], key });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Every declared index's structured definition. Collection walks this
|
|
69
|
+
* when materialising side-cars on put/delete so it can extract a
|
|
70
|
+
* single-field value or a composite tuple appropriately.
|
|
71
|
+
*/
|
|
72
|
+
definitions() {
|
|
73
|
+
return [...this.defs.values()];
|
|
74
|
+
}
|
|
75
|
+
/** True if `field` has been declared as indexable on this mirror. */
|
|
76
|
+
has(field) {
|
|
77
|
+
return this.indexes.has(field);
|
|
78
|
+
}
|
|
79
|
+
/** All declared field names, in declaration order. */
|
|
80
|
+
fields() {
|
|
81
|
+
return [...this.indexes.keys()];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bulk-load the mirror from decrypted index bodies. Intended to be
|
|
85
|
+
* called once per field after reading the collection's `_idx/<field>/*`
|
|
86
|
+
* side-cars. Safe to call twice with the same rows — bucket Sets
|
|
87
|
+
* deduplicate recordIds. If `field` is not declared, this is a no-op
|
|
88
|
+
* (tolerates the case where bulk-load runs before `declare()` lands).
|
|
89
|
+
*/
|
|
90
|
+
ingest(field, rows) {
|
|
91
|
+
const state = this.indexes.get(field);
|
|
92
|
+
if (!state) return;
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
addToState(state, row.recordId, row.value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Incrementally update a record's index entry for one field. Called by
|
|
99
|
+
* `Collection.put()` after the main write succeeds. If
|
|
100
|
+
* `previousValue` is non-null, the record is removed from the old
|
|
101
|
+
* bucket first — this is the update path. Pass `null` for fresh adds.
|
|
102
|
+
* No-op if the field is not declared.
|
|
103
|
+
*/
|
|
104
|
+
upsert(recordId, field, newValue, previousValue) {
|
|
105
|
+
const state = this.indexes.get(field);
|
|
106
|
+
if (!state) return;
|
|
107
|
+
if (previousValue !== null && previousValue !== void 0) {
|
|
108
|
+
removeFromState(state, recordId, previousValue);
|
|
109
|
+
}
|
|
110
|
+
addToState(state, recordId, newValue);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove a record from the index for one field. Called by
|
|
114
|
+
* `Collection.delete()`. No-op if the field is not declared or
|
|
115
|
+
* the record isn't in the bucket. Empty buckets are dropped to keep
|
|
116
|
+
* the Map clean.
|
|
117
|
+
*/
|
|
118
|
+
remove(recordId, field, value) {
|
|
119
|
+
const state = this.indexes.get(field);
|
|
120
|
+
if (!state) return;
|
|
121
|
+
removeFromState(state, recordId, value);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Drop all bucket data while preserving field declarations. Called on
|
|
125
|
+
* invalidation (incoming sync changes, keyring rotation) — the next
|
|
126
|
+
* query re-populates via `ingest`.
|
|
127
|
+
*/
|
|
128
|
+
clear() {
|
|
129
|
+
for (const state of this.indexes.values()) {
|
|
130
|
+
state.buckets.clear();
|
|
131
|
+
state.values.clear();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Equality lookup — return the set of record ids whose `field` matches
|
|
136
|
+
* `value`. Returns `null` if the field is not declared (caller falls
|
|
137
|
+
* back to scan or throws `IndexRequiredError`). Returns a shared empty
|
|
138
|
+
* set if the field is declared but no record matches — that set MUST
|
|
139
|
+
* NOT be mutated by the caller.
|
|
140
|
+
*/
|
|
141
|
+
lookupEqual(field, value) {
|
|
142
|
+
const state = this.indexes.get(field);
|
|
143
|
+
if (!state) return null;
|
|
144
|
+
const key = stringifyKey(value);
|
|
145
|
+
return state.buckets.get(key) ?? EMPTY_SET;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Set lookup — return the union of record ids whose `field` matches any
|
|
149
|
+
* of `values`. Returns `null` if the field is not declared. Returns a
|
|
150
|
+
* fresh (non-shared) Set — safe for the caller to mutate.
|
|
151
|
+
*/
|
|
152
|
+
lookupIn(field, values) {
|
|
153
|
+
const state = this.indexes.get(field);
|
|
154
|
+
if (!state) return null;
|
|
155
|
+
const out = /* @__PURE__ */ new Set();
|
|
156
|
+
for (const value of values) {
|
|
157
|
+
const bucket = state.buckets.get(stringifyKey(value));
|
|
158
|
+
if (bucket) for (const id of bucket) out.add(id);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Range lookup. Return record ids whose indexed value
|
|
164
|
+
* satisfies the predicate. Comparison happens on the ORIGINAL TYPED
|
|
165
|
+
* value carried in `state.values` — so numeric `<` sorts numerically,
|
|
166
|
+
* not lexicographically on `String(n)`. Returns `null` if the field
|
|
167
|
+
* is not declared.
|
|
168
|
+
*
|
|
169
|
+
* Supported ops: `'<'`, `'<='`, `'>'`, `'>='`, `'between'`. For
|
|
170
|
+
* `'between'`, `value` is `[lo, hi]` and both bounds are inclusive
|
|
171
|
+
* (matches the eager-mode operator contract in `predicate.ts`).
|
|
172
|
+
*/
|
|
173
|
+
lookupRange(field, op, value) {
|
|
174
|
+
const state = this.indexes.get(field);
|
|
175
|
+
if (!state) return null;
|
|
176
|
+
const out = /* @__PURE__ */ new Set();
|
|
177
|
+
for (const [recordId, live] of state.values) {
|
|
178
|
+
if (live === void 0 || live === null) continue;
|
|
179
|
+
if (matchesRange(live, op, value)) out.add(recordId);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Sorted iteration — return every entry on `field` as an
|
|
185
|
+
* `OrderedEntry[]`, sorted by the ORIGINAL TYPED value (#275: no more
|
|
186
|
+
* `'10' < '2'` surprises on numeric fields). Consumers paginate with
|
|
187
|
+
* a numeric offset. `OrderedEntry.value` is the typed value.
|
|
188
|
+
*/
|
|
189
|
+
orderedBy(field, dir) {
|
|
190
|
+
const state = this.indexes.get(field);
|
|
191
|
+
if (!state) return null;
|
|
192
|
+
const entries = [];
|
|
193
|
+
for (const [recordId, value] of state.values) {
|
|
194
|
+
entries.push({ recordId, value });
|
|
195
|
+
}
|
|
196
|
+
entries.sort((a, b) => compareTyped(a.value, b.value));
|
|
197
|
+
if (dir === "desc") entries.reverse();
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var EMPTY_SET = /* @__PURE__ */ new Set();
|
|
202
|
+
function stringifyKey(value) {
|
|
203
|
+
if (value === null || value === void 0) return "\0NULL\0";
|
|
204
|
+
if (typeof value === "string") return value;
|
|
205
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
206
|
+
if (value instanceof Date) return value.toISOString();
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (const el of value) parts.push(stringifyKey(el));
|
|
210
|
+
return JSON.stringify(parts);
|
|
211
|
+
}
|
|
212
|
+
return "\0OBJECT\0";
|
|
213
|
+
}
|
|
214
|
+
function addToState(state, recordId, value) {
|
|
215
|
+
if (value === null || value === void 0) return;
|
|
216
|
+
const key = stringifyKey(value);
|
|
217
|
+
let bucket = state.buckets.get(key);
|
|
218
|
+
if (!bucket) {
|
|
219
|
+
bucket = /* @__PURE__ */ new Set();
|
|
220
|
+
state.buckets.set(key, bucket);
|
|
221
|
+
}
|
|
222
|
+
bucket.add(recordId);
|
|
223
|
+
state.values.set(recordId, value);
|
|
224
|
+
}
|
|
225
|
+
function removeFromState(state, recordId, value) {
|
|
226
|
+
if (value === null || value === void 0) return;
|
|
227
|
+
const key = stringifyKey(value);
|
|
228
|
+
const bucket = state.buckets.get(key);
|
|
229
|
+
if (bucket) {
|
|
230
|
+
bucket.delete(recordId);
|
|
231
|
+
if (bucket.size === 0) state.buckets.delete(key);
|
|
232
|
+
}
|
|
233
|
+
state.values.delete(recordId);
|
|
234
|
+
}
|
|
235
|
+
function matchesRange(live, op, bound) {
|
|
236
|
+
if (op === "between") {
|
|
237
|
+
if (!Array.isArray(bound) || bound.length !== 2) return false;
|
|
238
|
+
return compareTyped(live, bound[0]) >= 0 && compareTyped(live, bound[1]) <= 0;
|
|
239
|
+
}
|
|
240
|
+
const cmp = compareTyped(live, bound);
|
|
241
|
+
switch (op) {
|
|
242
|
+
case "<":
|
|
243
|
+
return cmp < 0;
|
|
244
|
+
case "<=":
|
|
245
|
+
return cmp <= 0;
|
|
246
|
+
case ">":
|
|
247
|
+
return cmp > 0;
|
|
248
|
+
case ">=":
|
|
249
|
+
return cmp >= 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function compareTyped(a, b) {
|
|
253
|
+
if (a === void 0 || a === null) return b === void 0 || b === null ? 0 : 1;
|
|
254
|
+
if (b === void 0 || b === null) return -1;
|
|
255
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
256
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
257
|
+
if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0;
|
|
258
|
+
if (typeof a === "boolean" && typeof b === "boolean") {
|
|
259
|
+
return a === b ? 0 : a ? 1 : -1;
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/indexing/lazy-builder.ts
|
|
265
|
+
var EMPTY_PLAN = {
|
|
266
|
+
clauses: [],
|
|
267
|
+
orderBy: [],
|
|
268
|
+
limit: void 0,
|
|
269
|
+
offset: 0
|
|
270
|
+
};
|
|
271
|
+
var LazyQuery = class _LazyQuery {
|
|
272
|
+
source;
|
|
273
|
+
plan;
|
|
274
|
+
constructor(source, plan = EMPTY_PLAN) {
|
|
275
|
+
this.source = source;
|
|
276
|
+
this.plan = plan;
|
|
277
|
+
}
|
|
278
|
+
where(field, op, value) {
|
|
279
|
+
const clause = { type: "field", field, op, value };
|
|
280
|
+
return new _LazyQuery(this.source, {
|
|
281
|
+
...this.plan,
|
|
282
|
+
clauses: [...this.plan.clauses, clause]
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
orderBy(field, direction = "asc") {
|
|
286
|
+
return new _LazyQuery(this.source, {
|
|
287
|
+
...this.plan,
|
|
288
|
+
orderBy: [...this.plan.orderBy, { field, direction }]
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
limit(n) {
|
|
292
|
+
return new _LazyQuery(this.source, { ...this.plan, limit: n });
|
|
293
|
+
}
|
|
294
|
+
offset(n) {
|
|
295
|
+
return new _LazyQuery(this.source, { ...this.plan, offset: n });
|
|
296
|
+
}
|
|
297
|
+
async toArray() {
|
|
298
|
+
await this.source.ensurePersistedIndexesLoaded();
|
|
299
|
+
const touchedFields = collectTouchedFields(this.plan);
|
|
300
|
+
const missingFields = touchedFields.filter((f) => !isFieldIndexed(f, this.source.persistedIndexes));
|
|
301
|
+
if (missingFields.length > 0) {
|
|
302
|
+
throw new IndexRequiredError({
|
|
303
|
+
collection: this.source.collectionName,
|
|
304
|
+
touchedFields,
|
|
305
|
+
missingFields
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const candidateIds = this.resolveCandidateIds();
|
|
309
|
+
if (candidateIds === null) {
|
|
310
|
+
throw new IndexRequiredError({
|
|
311
|
+
collection: this.source.collectionName,
|
|
312
|
+
touchedFields,
|
|
313
|
+
missingFields: touchedFields
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const records = [];
|
|
317
|
+
for (const id of candidateIds) {
|
|
318
|
+
const record = await this.source.getRecord(id);
|
|
319
|
+
if (record === null) continue;
|
|
320
|
+
if (!matchesAll(record, this.plan.clauses)) continue;
|
|
321
|
+
records.push(record);
|
|
322
|
+
}
|
|
323
|
+
const sorted = this.plan.orderBy.length > 0 ? sortRecords(records, this.plan.orderBy) : records;
|
|
324
|
+
const offset = this.plan.offset > 0 ? this.plan.offset : 0;
|
|
325
|
+
const limited = this.plan.limit === void 0 ? sorted.slice(offset) : sorted.slice(offset, offset + this.plan.limit);
|
|
326
|
+
return limited;
|
|
327
|
+
}
|
|
328
|
+
async first() {
|
|
329
|
+
const out = await this.limit(1).toArray();
|
|
330
|
+
return out.length > 0 ? out[0] : null;
|
|
331
|
+
}
|
|
332
|
+
async count() {
|
|
333
|
+
const out = await this.toArray();
|
|
334
|
+
return out.length;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Resolve the candidate record-id set to decrypt. Returns null when the
|
|
338
|
+
* query has no usable driver — no `==`/`in` clause and no `orderBy`
|
|
339
|
+
* clause that can scope the scan. Callers interpret null as
|
|
340
|
+
* IndexRequiredError (see `toArray`).
|
|
341
|
+
*/
|
|
342
|
+
resolveCandidateIds() {
|
|
343
|
+
const idx = this.source.persistedIndexes;
|
|
344
|
+
const eqMap = /* @__PURE__ */ new Map();
|
|
345
|
+
for (const clause of this.plan.clauses) {
|
|
346
|
+
if (clause.op === "==") eqMap.set(clause.field, clause.value);
|
|
347
|
+
}
|
|
348
|
+
if (eqMap.size >= 2) {
|
|
349
|
+
for (const def of idx.definitions()) {
|
|
350
|
+
if (def.kind !== "composite") continue;
|
|
351
|
+
if (def.fields.every((f) => eqMap.has(f))) {
|
|
352
|
+
const tuple = def.fields.map((f) => eqMap.get(f));
|
|
353
|
+
const ids = idx.lookupEqual(def.key, tuple);
|
|
354
|
+
if (ids) return [...ids];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const clause of this.plan.clauses) {
|
|
359
|
+
if (clause.op === "==") {
|
|
360
|
+
const ids = idx.lookupEqual(clause.field, clause.value);
|
|
361
|
+
if (ids) return [...ids];
|
|
362
|
+
} else if (clause.op === "in" && Array.isArray(clause.value)) {
|
|
363
|
+
const ids = idx.lookupIn(clause.field, clause.value);
|
|
364
|
+
if (ids) return [...ids];
|
|
365
|
+
} else if (isRangeOp(clause.op)) {
|
|
366
|
+
const ids = idx.lookupRange(clause.field, clause.op, clause.value);
|
|
367
|
+
if (ids) return [...ids];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (this.plan.orderBy.length > 0) {
|
|
371
|
+
const primary = this.plan.orderBy[0];
|
|
372
|
+
const entries = idx.orderedBy(primary.field, primary.direction);
|
|
373
|
+
if (entries) return entries.map((e) => e.recordId);
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
function isFieldIndexed(field, idx) {
|
|
379
|
+
if (idx.has(field)) return true;
|
|
380
|
+
for (const def of idx.definitions()) {
|
|
381
|
+
if (def.kind === "composite" && def.fields.includes(field)) return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
function isRangeOp(op) {
|
|
386
|
+
return op === "<" || op === "<=" || op === ">" || op === ">=" || op === "between";
|
|
387
|
+
}
|
|
388
|
+
function collectTouchedFields(plan) {
|
|
389
|
+
const seen = /* @__PURE__ */ new Set();
|
|
390
|
+
for (const c of plan.clauses) seen.add(c.field);
|
|
391
|
+
for (const o of plan.orderBy) seen.add(o.field);
|
|
392
|
+
return [...seen];
|
|
393
|
+
}
|
|
394
|
+
function matchesAll(record, clauses) {
|
|
395
|
+
for (const c of clauses) {
|
|
396
|
+
if (!evaluateClause(record, c)) return false;
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
function sortRecords(records, orderBy) {
|
|
401
|
+
return [...records].sort((a, b) => {
|
|
402
|
+
for (const { field, direction } of orderBy) {
|
|
403
|
+
const av = readPath(a, field);
|
|
404
|
+
const bv = readPath(b, field);
|
|
405
|
+
const cmp = compareValues(av, bv);
|
|
406
|
+
if (cmp !== 0) return direction === "asc" ? cmp : -cmp;
|
|
407
|
+
}
|
|
408
|
+
return 0;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function compareValues(a, b) {
|
|
412
|
+
if (a === void 0 || a === null) return b === void 0 || b === null ? 0 : 1;
|
|
413
|
+
if (b === void 0 || b === null) return -1;
|
|
414
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
415
|
+
if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0;
|
|
416
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export {
|
|
421
|
+
IDX_PREFIX,
|
|
422
|
+
encodeIdxId,
|
|
423
|
+
decodeIdxId,
|
|
424
|
+
isIdxId,
|
|
425
|
+
COMPOSITE_DELIMITER,
|
|
426
|
+
compositeKey,
|
|
427
|
+
PersistedCollectionIndex,
|
|
428
|
+
LazyQuery
|
|
429
|
+
};
|
|
430
|
+
//# sourceMappingURL=chunk-HG2OWBLX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/indexing/persisted-indexes.ts","../src/indexing/lazy-builder.ts"],"sourcesContent":["/**\n * Persistent, encrypted secondary indexes for lazy-mode collections.\n *\n * Parallel to the in-memory `CollectionIndexes` used by eager mode (see\n * `packages/hub/src/query/indexes.ts`): same logical surface, but entries\n * are materialised as encrypted side-car records (`_idx/<field>/<recordId>`)\n * and bulk-loaded into an in-memory mirror on first query.\n *\n * This module only owns the id-namespace convention, the in-memory mirror,\n * and the typed errors. Write-path integration (PR 2 / ), query-planner\n * dispatch (PR 3 / , PR 4 / ), and the rebuild/reconcile utilities\n * (PR 5 / ) live in other files.\n *\n * See the design spec for the full architecture + threat model.\n */\n\n/**\n * Reserved id prefix for encrypted index side-car records.\n * Matches the existing `_keyring`, `_ledger_deltas/…`, `_meta/handle`\n * conventions inside a collection's id namespace.\n */\nexport const IDX_PREFIX = '_idx/' as const\n\n/**\n * Encode the side-car record id for a (field, recordId) pair.\n *\n * Format: `_idx/<field>/<recordId>` — no escaping. Field names may contain\n * dots (for dotted-path access consistent with eager-mode `readPath`);\n * record ids may contain slashes. The first two slash-separated segments\n * are `_idx` and the field; everything after the *second* slash is the\n * record id verbatim.\n */\nexport function encodeIdxId(field: string, recordId: string): string {\n return `${IDX_PREFIX}${field}/${recordId}`\n}\n\n/**\n * Decode a side-car id back into `{ field, recordId }`, or `null` if the\n * input is not a well-formed idx id. A well-formed id is:\n * - prefixed with `_idx/`\n * - contains a field segment (non-empty, no slashes)\n * - contains a record-id segment (non-empty, may contain slashes)\n */\nexport function decodeIdxId(id: string): { field: string; recordId: string } | null {\n if (!id.startsWith(IDX_PREFIX)) return null\n const rest = id.slice(IDX_PREFIX.length)\n const firstSlash = rest.indexOf('/')\n if (firstSlash <= 0) return null\n const field = rest.slice(0, firstSlash)\n const recordId = rest.slice(firstSlash + 1)\n if (recordId.length === 0) return null\n return { field, recordId }\n}\n\n/**\n * Fast-path predicate for discriminating side-car ids from regular record\n * ids and other reserved namespaces. Used by the hub to filter `list()`\n * results during bulk-load of the in-memory mirror.\n */\nexport function isIdxId(id: string): boolean {\n return decodeIdxId(id) !== null\n}\n\n/**\n * Sorted-value entry returned by `orderedBy()`. Mirrors the body shape\n * used by the write path — but `orderedBy` emits them already sorted by\n * `value` in the requested direction. Consumers (PR 4 / ) treat the\n * array as immutable and paginate via a numeric offset.\n *\n * **Note on `value`:** as of, this is the ORIGINAL TYPED\n * value (number, Date, boolean, etc.), not the stringified bucket key.\n * That's what lets range predicates and `orderedBy` compare numerically\n * instead of stumbling into `'10' < '2'` on `String(n)`.\n */\nexport interface OrderedEntry {\n readonly recordId: string\n readonly value: unknown\n}\n\n/**\n * Bulk-load row shape accepted by `ingest()`. The `value` field is the\n * decrypted index body's `value` field verbatim.\n */\nexport interface IngestRow {\n readonly recordId: string\n readonly value: unknown\n}\n\n/**\n * In-memory mirror of the persisted index side-car records for a single\n * collection. Populated by bulk-loading `_idx/<field>/*` ids on first\n * query and maintained incrementally by `Collection.put()` / `.delete()`\n * via `upsert()` / `remove()`.\n *\n * API surface is deliberately parallel to `CollectionIndexes` (eager mode)\n * so the query planner in PR 3/4 can dispatch to either polymorphically.\n *\n * Lifecycle:\n * - `declare(field)` — accept the field as indexable (idempotent)\n * - `ingest(field, rows[])` — bulk-load from decrypted index bodies\n * - `upsert(recordId, field, newValue, previousValue)` — incremental update\n * - `remove(recordId, field, value)` — incremental remove\n * - `lookupEqual(field, value)` / `lookupIn(field, values)` — equality reads\n * - `orderedBy(field, dir)` — sorted iteration for orderBy\n * - `clear()` — drop all buckets (invalidation / rotation)\n */\n/**\n * Per-field storage: the equality bucket map AND a parallel table of typed\n * values keyed by recordId. The typed table exists so range predicates\n * and `orderedBy` can compare on the original typed value rather\n * than the stringified bucket key — String(10) < String(2) is the classic\n * landmine `stringifyKey` introduces for numeric fields.\n */\ninterface PersistedFieldState {\n readonly buckets: Map<string, Set<string>>\n readonly values: Map<string, unknown>\n}\n\n/**\n * Structured index definition. Single-field indexes carry just a field\n * name; composite indexes carry the ordered list of fields and\n * the synthetic `key` (= fields joined by `COMPOSITE_DELIMITER`) used\n * as the bucket-map key and side-car envelope id segment.\n */\nexport type PersistedIndexDef =\n | { readonly kind: 'single'; readonly field: string; readonly key: string }\n | { readonly kind: 'composite'; readonly fields: readonly string[]; readonly key: string }\n\n/**\n * Delimiter used to synthesize a composite-index key from an ordered\n * field list. Intentionally a character that is extremely unusual in\n * JavaScript object keys (`|`) so collision with a literal field name\n * is vanishingly rare in practice. Composite declarations whose field\n * names contain `|` are rejected at declare-time with an explicit\n * error.\n */\nexport const COMPOSITE_DELIMITER = '|'\n\nexport function compositeKey(fields: readonly string[]): string {\n return fields.join(COMPOSITE_DELIMITER)\n}\n\nexport class PersistedCollectionIndex {\n private readonly indexes = new Map<string, PersistedFieldState>()\n private readonly defs = new Map<string, PersistedIndexDef>()\n\n /**\n * Declare a single-field index. Subsequent `upsert` / `ingest` calls\n * populate the in-memory mirror; calls before `declare` are no-ops\n * (tolerant bulk-load ordering). Idempotent.\n */\n declare(field: string): void {\n if (this.indexes.has(field)) return\n this.indexes.set(field, { buckets: new Map(), values: new Map() })\n this.defs.set(field, { kind: 'single', field, key: field })\n }\n\n /**\n * Declare a composite (multi-field) index. The synthetic\n * key is `fields.join('|')`; it doubles as the in-memory map key and\n * the `_idx/<key>/<recordId>` side-car field segment. Callers upsert\n * and lookup via the same `key` as single-field indexes, just with a\n * tuple value (JSON-stringified for bucketing).\n */\n declareComposite(fields: readonly string[]): void {\n if (fields.length === 0) {\n throw new Error('declareComposite: fields array must be non-empty')\n }\n for (const f of fields) {\n if (f.includes(COMPOSITE_DELIMITER)) {\n throw new Error(\n `declareComposite: field \"${f}\" contains the composite delimiter ` +\n `\"${COMPOSITE_DELIMITER}\" — pick a different field name or open an ` +\n `issue to add hash-based composite keys.`,\n )\n }\n }\n const key = compositeKey(fields)\n if (this.indexes.has(key)) return\n this.indexes.set(key, { buckets: new Map(), values: new Map() })\n this.defs.set(key, { kind: 'composite', fields: [...fields], key })\n }\n\n /**\n * Every declared index's structured definition. Collection walks this\n * when materialising side-cars on put/delete so it can extract a\n * single-field value or a composite tuple appropriately.\n */\n definitions(): PersistedIndexDef[] {\n return [...this.defs.values()]\n }\n\n /** True if `field` has been declared as indexable on this mirror. */\n has(field: string): boolean {\n return this.indexes.has(field)\n }\n\n /** All declared field names, in declaration order. */\n fields(): string[] {\n return [...this.indexes.keys()]\n }\n\n /**\n * Bulk-load the mirror from decrypted index bodies. Intended to be\n * called once per field after reading the collection's `_idx/<field>/*`\n * side-cars. Safe to call twice with the same rows — bucket Sets\n * deduplicate recordIds. If `field` is not declared, this is a no-op\n * (tolerates the case where bulk-load runs before `declare()` lands).\n */\n ingest(field: string, rows: readonly IngestRow[]): void {\n const state = this.indexes.get(field)\n if (!state) return\n for (const row of rows) {\n addToState(state, row.recordId, row.value)\n }\n }\n\n /**\n * Incrementally update a record's index entry for one field. Called by\n * `Collection.put()` after the main write succeeds. If\n * `previousValue` is non-null, the record is removed from the old\n * bucket first — this is the update path. Pass `null` for fresh adds.\n * No-op if the field is not declared.\n */\n upsert(recordId: string, field: string, newValue: unknown, previousValue: unknown): void {\n const state = this.indexes.get(field)\n if (!state) return\n if (previousValue !== null && previousValue !== undefined) {\n removeFromState(state, recordId, previousValue)\n }\n addToState(state, recordId, newValue)\n }\n\n /**\n * Remove a record from the index for one field. Called by\n * `Collection.delete()`. No-op if the field is not declared or\n * the record isn't in the bucket. Empty buckets are dropped to keep\n * the Map clean.\n */\n remove(recordId: string, field: string, value: unknown): void {\n const state = this.indexes.get(field)\n if (!state) return\n removeFromState(state, recordId, value)\n }\n\n /**\n * Drop all bucket data while preserving field declarations. Called on\n * invalidation (incoming sync changes, keyring rotation) — the next\n * query re-populates via `ingest`.\n */\n clear(): void {\n for (const state of this.indexes.values()) {\n state.buckets.clear()\n state.values.clear()\n }\n }\n\n /**\n * Equality lookup — return the set of record ids whose `field` matches\n * `value`. Returns `null` if the field is not declared (caller falls\n * back to scan or throws `IndexRequiredError`). Returns a shared empty\n * set if the field is declared but no record matches — that set MUST\n * NOT be mutated by the caller.\n */\n lookupEqual(field: string, value: unknown): ReadonlySet<string> | null {\n const state = this.indexes.get(field)\n if (!state) return null\n const key = stringifyKey(value)\n return state.buckets.get(key) ?? EMPTY_SET\n }\n\n /**\n * Set lookup — return the union of record ids whose `field` matches any\n * of `values`. Returns `null` if the field is not declared. Returns a\n * fresh (non-shared) Set — safe for the caller to mutate.\n */\n lookupIn(field: string, values: readonly unknown[]): ReadonlySet<string> | null {\n const state = this.indexes.get(field)\n if (!state) return null\n const out = new Set<string>()\n for (const value of values) {\n const bucket = state.buckets.get(stringifyKey(value))\n if (bucket) for (const id of bucket) out.add(id)\n }\n return out\n }\n\n /**\n * Range lookup. Return record ids whose indexed value\n * satisfies the predicate. Comparison happens on the ORIGINAL TYPED\n * value carried in `state.values` — so numeric `<` sorts numerically,\n * not lexicographically on `String(n)`. Returns `null` if the field\n * is not declared.\n *\n * Supported ops: `'<'`, `'<='`, `'>'`, `'>='`, `'between'`. For\n * `'between'`, `value` is `[lo, hi]` and both bounds are inclusive\n * (matches the eager-mode operator contract in `predicate.ts`).\n */\n lookupRange(\n field: string,\n op: '<' | '<=' | '>' | '>=' | 'between',\n value: unknown,\n ): ReadonlySet<string> | null {\n const state = this.indexes.get(field)\n if (!state) return null\n const out = new Set<string>()\n for (const [recordId, live] of state.values) {\n if (live === undefined || live === null) continue\n if (matchesRange(live, op, value)) out.add(recordId)\n }\n return out\n }\n\n /**\n * Sorted iteration — return every entry on `field` as an\n * `OrderedEntry[]`, sorted by the ORIGINAL TYPED value (#275: no more\n * `'10' < '2'` surprises on numeric fields). Consumers paginate with\n * a numeric offset. `OrderedEntry.value` is the typed value.\n */\n orderedBy(field: string, dir: 'asc' | 'desc'): readonly OrderedEntry[] | null {\n const state = this.indexes.get(field)\n if (!state) return null\n const entries: OrderedEntry[] = []\n for (const [recordId, value] of state.values) {\n entries.push({ recordId, value })\n }\n entries.sort((a, b) => compareTyped(a.value, b.value))\n if (dir === 'desc') entries.reverse()\n return entries\n }\n}\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\n\n/**\n * Canonicalize a value into a bucket key. Deliberately identical to the\n * eager-mode `stringifyKey` in `query/indexes.ts` so semantics match. When\n * `query/indexes.ts` changes its coercion rules, update this in lockstep.\n *\n * null / undefined values are NOT indexed — callers who pass them to\n * `upsert` / `remove` short-circuit before reaching this function; the\n * sentinel here exists only to make `lookupEqual(field, null)` return\n * an empty bucket (rather than matching some arbitrary record).\n */\nfunction stringifyKey(value: unknown): string {\n if (value === null || value === undefined) return '\\0NULL\\0'\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (value instanceof Date) return value.toISOString()\n // composite index values are tuple arrays. JSON.stringify\n // gives a delimiter-safe, order-preserving canonical form so buckets\n // for `['c-A', '2026-Q1']` and `['c-A', '2026-Q2']` never collide.\n if (Array.isArray(value)) {\n const parts: string[] = []\n for (const el of value) parts.push(stringifyKey(el))\n return JSON.stringify(parts)\n }\n return '\\0OBJECT\\0'\n}\n\nfunction addToState(state: PersistedFieldState, recordId: string, value: unknown): void {\n if (value === null || value === undefined) return\n const key = stringifyKey(value)\n let bucket = state.buckets.get(key)\n if (!bucket) {\n bucket = new Set()\n state.buckets.set(key, bucket)\n }\n bucket.add(recordId)\n state.values.set(recordId, value)\n}\n\nfunction removeFromState(state: PersistedFieldState, recordId: string, value: unknown): void {\n if (value === null || value === undefined) return\n const key = stringifyKey(value)\n const bucket = state.buckets.get(key)\n if (bucket) {\n bucket.delete(recordId)\n if (bucket.size === 0) state.buckets.delete(key)\n }\n state.values.delete(recordId)\n}\n\n/**\n * Range-predicate comparator. Runs on the ORIGINAL TYPED value so numeric\n * fields sort numerically (not lexicographically on `String(n)`). ISO-8601\n * date strings already sort correctly lexicographically; Date instances\n * compare via `getTime()` before the string branch to keep the contract\n * honest regardless of which form survived serialization.\n */\nfunction matchesRange(\n live: unknown,\n op: '<' | '<=' | '>' | '>=' | 'between',\n bound: unknown,\n): boolean {\n if (op === 'between') {\n if (!Array.isArray(bound) || bound.length !== 2) return false\n return compareTyped(live, bound[0]) >= 0 && compareTyped(live, bound[1]) <= 0\n }\n const cmp = compareTyped(live, bound)\n switch (op) {\n case '<': return cmp < 0\n case '<=': return cmp <= 0\n case '>': return cmp > 0\n case '>=': return cmp >= 0\n }\n}\n\nfunction compareTyped(a: unknown, b: unknown): number {\n if (a === undefined || a === null) return b === undefined || b === null ? 0 : 1\n if (b === undefined || b === null) return -1\n if (typeof a === 'number' && typeof b === 'number') return a - b\n if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime()\n if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0\n if (typeof a === 'boolean' && typeof b === 'boolean') {\n return a === b ? 0 : a ? 1 : -1\n }\n // Mixed/unsupported types: deliberately treat as equal so sort stays\n // stable. Matches the eager-mode `compareValues` contract in\n // builder.ts — we don't silently coerce arbitrary objects to strings\n // (which would be meaningless) nor throw (which would be hostile).\n return 0\n}\n","/**\n * Lazy-mode query builder.\n *\n * Companion to `Query<T>` in `builder.ts`, but built for collections in lazy\n * mode where `snapshot()` is unavailable — records live in the adapter and\n * are pulled on demand. Dispatches through `PersistedCollectionIndex` to\n * resolve a candidate record-id set, then decrypts only those records.\n *\n * Scope:\n * - `.where(field, '==' | 'in', value)` — dispatched through the index\n * - `.where(field, other-op, value)` — evaluated against the decrypted\n * candidate set (non-indexed ops still require the field to be indexed\n * — we need SOMETHING to scope the candidate set)\n * - `.orderBy(field, dir?)` — dispatched through `orderedBy` when no\n * `==`/`in` clause is present; otherwise applied as an in-memory sort\n * over the candidate set\n * - `.limit(n)` / `.offset(n)` — page slice after filtering\n * - `.toArray()` / `.first()` / `.count()` — terminals\n *\n * Every field referenced by a where or orderBy clause MUST be indexed;\n * otherwise `toArray()` throws `IndexRequiredError`. This is deliberate:\n * silent scan-fallback would hide the very performance cliff that lazy-mode\n * indexes exist to prevent (see `docs/architecture.md` §indexes).\n */\n\nimport type { Clause, FieldClause, Operator } from '../query/predicate.js'\nimport { evaluateClause, readPath } from '../query/predicate.js'\nimport type { PersistedCollectionIndex } from './persisted-indexes.js'\nimport { IndexRequiredError } from '../errors.js'\n\nexport interface LazyOrderBy {\n readonly field: string\n readonly direction: 'asc' | 'desc'\n}\n\n/**\n * Source abstraction the LazyQuery runs against. Collection implements it.\n * Kept minimal so the builder stays test-friendly.\n */\nexport interface LazyQuerySource<T> {\n readonly collectionName: string\n readonly persistedIndexes: PersistedCollectionIndex\n /** Ensure `_idx/<field>/*` side-cars have been bulk-loaded into the mirror. */\n ensurePersistedIndexesLoaded(): Promise<void>\n /** Decrypt one record by id, or return null if it's gone. */\n getRecord(id: string): Promise<T | null>\n}\n\ninterface LazyPlan {\n readonly clauses: readonly FieldClause[]\n readonly orderBy: readonly LazyOrderBy[]\n readonly limit: number | undefined\n readonly offset: number\n}\n\nconst EMPTY_PLAN: LazyPlan = {\n clauses: [],\n orderBy: [],\n limit: undefined,\n offset: 0,\n}\n\nexport class LazyQuery<T> {\n private readonly source: LazyQuerySource<T>\n private readonly plan: LazyPlan\n\n constructor(source: LazyQuerySource<T>, plan: LazyPlan = EMPTY_PLAN) {\n this.source = source\n this.plan = plan\n }\n\n where<V>(field: string, op: Operator, value: V): LazyQuery<T> {\n const clause: FieldClause = { type: 'field', field, op, value }\n return new LazyQuery<T>(this.source, {\n ...this.plan,\n clauses: [...this.plan.clauses, clause],\n })\n }\n\n orderBy(field: string, direction: 'asc' | 'desc' = 'asc'): LazyQuery<T> {\n return new LazyQuery<T>(this.source, {\n ...this.plan,\n orderBy: [...this.plan.orderBy, { field, direction }],\n })\n }\n\n limit(n: number): LazyQuery<T> {\n return new LazyQuery<T>(this.source, { ...this.plan, limit: n })\n }\n\n offset(n: number): LazyQuery<T> {\n return new LazyQuery<T>(this.source, { ...this.plan, offset: n })\n }\n\n async toArray(): Promise<T[]> {\n await this.source.ensurePersistedIndexesLoaded()\n\n const touchedFields = collectTouchedFields(this.plan)\n const missingFields = touchedFields.filter(f => !isFieldIndexed(f, this.source.persistedIndexes))\n if (missingFields.length > 0) {\n throw new IndexRequiredError({\n collection: this.source.collectionName,\n touchedFields,\n missingFields,\n })\n }\n\n const candidateIds = this.resolveCandidateIds()\n if (candidateIds === null) {\n // No usable driver — every touched field is indexed but no clause\n // pins the candidate set. This happens when a query only uses\n // operators other than `==`/`in` and no `orderBy` clause is\n // present — we refuse to enumerate the whole index, because that\n // defeats the purpose of lazy mode.\n throw new IndexRequiredError({\n collection: this.source.collectionName,\n touchedFields,\n missingFields: touchedFields,\n })\n }\n\n const records: T[] = []\n for (const id of candidateIds) {\n const record = await this.source.getRecord(id)\n if (record === null) continue\n if (!matchesAll(record, this.plan.clauses)) continue\n records.push(record)\n }\n\n const sorted = this.plan.orderBy.length > 0\n ? sortRecords(records, this.plan.orderBy)\n : records\n\n const offset = this.plan.offset > 0 ? this.plan.offset : 0\n const limited = this.plan.limit === undefined\n ? sorted.slice(offset)\n : sorted.slice(offset, offset + this.plan.limit)\n\n return limited\n }\n\n async first(): Promise<T | null> {\n const out = await this.limit(1).toArray()\n return out.length > 0 ? out[0]! : null\n }\n\n async count(): Promise<number> {\n const out = await this.toArray()\n return out.length\n }\n\n /**\n * Resolve the candidate record-id set to decrypt. Returns null when the\n * query has no usable driver — no `==`/`in` clause and no `orderBy`\n * clause that can scope the scan. Callers interpret null as\n * IndexRequiredError (see `toArray`).\n */\n private resolveCandidateIds(): readonly string[] | null {\n const idx = this.source.persistedIndexes\n\n // prefer a composite index when the query's `==`\n // clauses cover every field of one declared composite. The\n // composite mirror lookup is O(matches) vs single-field +\n // post-filter on the decrypted candidate set.\n const eqMap = new Map<string, unknown>()\n for (const clause of this.plan.clauses) {\n if (clause.op === '==') eqMap.set(clause.field, clause.value)\n }\n if (eqMap.size >= 2) {\n for (const def of idx.definitions()) {\n if (def.kind !== 'composite') continue\n if (def.fields.every(f => eqMap.has(f))) {\n const tuple = def.fields.map(f => eqMap.get(f))\n const ids = idx.lookupEqual(def.key, tuple)\n if (ids) return [...ids]\n }\n }\n }\n\n for (const clause of this.plan.clauses) {\n if (clause.op === '==') {\n const ids = idx.lookupEqual(clause.field, clause.value)\n if (ids) return [...ids]\n } else if (clause.op === 'in' && Array.isArray(clause.value)) {\n const ids = idx.lookupIn(clause.field, clause.value as readonly unknown[])\n if (ids) return [...ids]\n } else if (isRangeOp(clause.op)) {\n // range predicates on an indexed field dispatch\n // through `lookupRange`, which compares on the original typed\n // value (no numeric-lexicographic landmines).\n const ids = idx.lookupRange(clause.field, clause.op, clause.value)\n if (ids) return [...ids]\n }\n }\n\n // No equality/range driver — try to scope via orderBy.\n if (this.plan.orderBy.length > 0) {\n const primary = this.plan.orderBy[0]!\n const entries = idx.orderedBy(primary.field, primary.direction)\n if (entries) return entries.map(e => e.recordId)\n }\n\n return null\n }\n}\n\n/**\n * True if the given field name is covered by either a single-field\n * index or appears as a component of a declared composite index.\n * Composite coverage is sufficient for the missing-field check because\n * composite writes also maintain the in-memory mirror — the range /\n * orderBy / single-equality lookup paths fall through to decrypted\n * candidates that still get post-filtered by the composite clause.\n */\nfunction isFieldIndexed(field: string, idx: PersistedCollectionIndex): boolean {\n if (idx.has(field)) return true\n for (const def of idx.definitions()) {\n if (def.kind === 'composite' && def.fields.includes(field)) return true\n }\n return false\n}\n\nfunction isRangeOp(op: Operator): op is '<' | '<=' | '>' | '>=' | 'between' {\n return op === '<' || op === '<=' || op === '>' || op === '>=' || op === 'between'\n}\n\nfunction collectTouchedFields(plan: LazyPlan): string[] {\n const seen = new Set<string>()\n for (const c of plan.clauses) seen.add(c.field)\n for (const o of plan.orderBy) seen.add(o.field)\n return [...seen]\n}\n\nfunction matchesAll(record: unknown, clauses: readonly Clause[]): boolean {\n for (const c of clauses) {\n if (!evaluateClause(record, c)) return false\n }\n return true\n}\n\nfunction sortRecords<T>(records: T[], orderBy: readonly LazyOrderBy[]): T[] {\n return [...records].sort((a, b) => {\n for (const { field, direction } of orderBy) {\n const av = readPath(a, field)\n const bv = readPath(b, field)\n const cmp = compareValues(av, bv)\n if (cmp !== 0) return direction === 'asc' ? cmp : -cmp\n }\n return 0\n })\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a === undefined || a === null) return b === undefined || b === null ? 0 : 1\n if (b === undefined || b === null) return -1\n if (typeof a === 'number' && typeof b === 'number') return a - b\n if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0\n if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime()\n return 0\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TierNotGrantedError
|
|
3
|
+
} from "./chunk-NBYQNDXA.js";
|
|
4
|
+
|
|
5
|
+
// src/team/tiers.ts
|
|
6
|
+
function dekKey(collection, tier) {
|
|
7
|
+
if (tier <= 0) return collection;
|
|
8
|
+
return `${collection}#${tier}`;
|
|
9
|
+
}
|
|
10
|
+
function effectiveClearance(keyring, collection) {
|
|
11
|
+
let max = 0;
|
|
12
|
+
const prefix = `${collection}#`;
|
|
13
|
+
for (const key of keyring.deks.keys()) {
|
|
14
|
+
if (!key.startsWith(prefix)) continue;
|
|
15
|
+
const suffix = key.slice(prefix.length);
|
|
16
|
+
const n = Number.parseInt(suffix, 10);
|
|
17
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
18
|
+
}
|
|
19
|
+
return max;
|
|
20
|
+
}
|
|
21
|
+
function assertTierAccess(keyring, collection, tier) {
|
|
22
|
+
if (tier <= 0) return;
|
|
23
|
+
if (keyring.role === "owner" || keyring.role === "admin") return;
|
|
24
|
+
if (!keyring.deks.has(dekKey(collection, tier))) {
|
|
25
|
+
throw new TierNotGrantedError(collection, tier);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
dekKey,
|
|
31
|
+
effectiveClearance,
|
|
32
|
+
assertTierAccess
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=chunk-IGAROPKM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/team/tiers.ts"],"sourcesContent":["/**\n * Hierarchical access — tier-aware keyring helpers.\n *\n * The keyring's existing `deks: Map<string, CryptoKey>` is keyed by\n * collection name. extends the key space:\n *\n * `'invoices'` — tier-0 DEK (unchanged from v0.x)\n * `'invoices#1'` — tier-1 DEK\n * `'invoices#2'` — tier-2 DEK\n *\n * Tier 0 keeps the bare collection name so any keyring written\n * before tiers existed loads without migration. Tiers ≥ 1 use `#N`\n * suffixes that\n * would be invalid as user-supplied collection names (see\n * `ReservedCollectionNameError` — `#` is reserved).\n *\n * @module\n */\n\nimport type { UnlockedKeyring } from './keyring.js'\nimport { TierNotGrantedError } from '../errors.js'\n\n/** Canonical DEK key for a given collection + tier. Tier 0 → bare name. */\nexport function dekKey(collection: string, tier: number): string {\n if (tier <= 0) return collection\n return `${collection}#${tier}`\n}\n\n/**\n * Returns the user's effective clearance for a given collection: the\n * maximum tier for which their keyring holds a DEK. Falls back to 0\n * when the user has only the tier-0 DEK (or none — the getDEK caller\n * will raise separately).\n */\nexport function effectiveClearance(keyring: UnlockedKeyring, collection: string): number {\n let max = 0\n const prefix = `${collection}#`\n for (const key of keyring.deks.keys()) {\n if (!key.startsWith(prefix)) continue\n const suffix = key.slice(prefix.length)\n const n = Number.parseInt(suffix, 10)\n if (Number.isFinite(n) && n > max) max = n\n }\n return max\n}\n\n/**\n * Assert the caller is cleared for the requested tier. Owners and\n * admins always pass (they can mint any new tier DEK on demand);\n * other roles must already hold the tier DEK — via a prior grant or\n * an active delegation — otherwise this throws `TierNotGrantedError`.\n *\n * This gate runs BEFORE `getDEK()` on the mutation path so a\n * non-cleared operator never has the opportunity to silently\n * auto-create a tier DEK they shouldn't have.\n */\nexport function assertTierAccess(\n keyring: UnlockedKeyring,\n collection: string,\n tier: number,\n): void {\n if (tier <= 0) return\n if (keyring.role === 'owner' || keyring.role === 'admin') return\n if (!keyring.deks.has(dekKey(collection, tier))) {\n throw new TierNotGrantedError(collection, tier)\n }\n}\n"],"mappings":";;;;;AAuBO,SAAS,OAAO,YAAoB,MAAsB;AAC/D,MAAI,QAAQ,EAAG,QAAO;AACtB,SAAO,GAAG,UAAU,IAAI,IAAI;AAC9B;AAQO,SAAS,mBAAmB,SAA0B,YAA4B;AACvF,MAAI,MAAM;AACV,QAAM,SAAS,GAAG,UAAU;AAC5B,aAAW,OAAO,QAAQ,KAAK,KAAK,GAAG;AACrC,QAAI,CAAC,IAAI,WAAW,MAAM,EAAG;AAC7B,UAAM,SAAS,IAAI,MAAM,OAAO,MAAM;AACtC,UAAM,IAAI,OAAO,SAAS,QAAQ,EAAE;AACpC,QAAI,OAAO,SAAS,CAAC,KAAK,IAAI,IAAK,OAAM;AAAA,EAC3C;AACA,SAAO;AACT;AAYO,SAAS,iBACd,SACA,YACA,MACM;AACN,MAAI,QAAQ,EAAG;AACf,MAAI,QAAQ,SAAS,WAAW,QAAQ,SAAS,QAAS;AAC1D,MAAI,CAAC,QAAQ,KAAK,IAAI,OAAO,YAAY,IAAI,CAAC,GAAG;AAC/C,UAAM,IAAI,oBAAoB,YAAY,IAAI;AAAA,EAChD;AACF;","names":[]}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/crdt/crdt.ts
|
|
2
|
+
function resolveCrdtSnapshot(state) {
|
|
3
|
+
switch (state._crdt) {
|
|
4
|
+
case "lww-map": {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const [field, reg] of Object.entries(state.fields)) {
|
|
7
|
+
result[field] = reg.v;
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
case "rga": {
|
|
12
|
+
const dead = new Set(state.tombstones);
|
|
13
|
+
return state.items.filter((i) => !dead.has(i.nid)).map((i) => i.v);
|
|
14
|
+
}
|
|
15
|
+
case "yjs":
|
|
16
|
+
return state.update;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function mergeCrdtStates(a, b) {
|
|
20
|
+
if (a._crdt !== b._crdt) return a;
|
|
21
|
+
switch (a._crdt) {
|
|
22
|
+
case "lww-map":
|
|
23
|
+
return mergeLwwMap(a, b);
|
|
24
|
+
case "rga":
|
|
25
|
+
return mergeRga(a, b);
|
|
26
|
+
case "yjs":
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function mergeLwwMap(a, b) {
|
|
31
|
+
const merged = {};
|
|
32
|
+
const allFields = /* @__PURE__ */ new Set([...Object.keys(a.fields), ...Object.keys(b.fields)]);
|
|
33
|
+
for (const field of allFields) {
|
|
34
|
+
const fa = a.fields[field];
|
|
35
|
+
const fb = b.fields[field];
|
|
36
|
+
if (!fa) {
|
|
37
|
+
merged[field] = fb;
|
|
38
|
+
} else if (!fb) {
|
|
39
|
+
merged[field] = fa;
|
|
40
|
+
} else {
|
|
41
|
+
merged[field] = fa.ts >= fb.ts ? fa : fb;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { _crdt: "lww-map", fields: merged };
|
|
45
|
+
}
|
|
46
|
+
function mergeRga(a, b) {
|
|
47
|
+
const allTombstones = /* @__PURE__ */ new Set([...a.tombstones, ...b.tombstones]);
|
|
48
|
+
const seenNids = new Set(a.items.map((i) => i.nid));
|
|
49
|
+
const merged = [
|
|
50
|
+
...a.items,
|
|
51
|
+
...b.items.filter((i) => !seenNids.has(i.nid))
|
|
52
|
+
];
|
|
53
|
+
return { _crdt: "rga", items: merged, tombstones: [...allTombstones] };
|
|
54
|
+
}
|
|
55
|
+
function buildLwwMapState(record, existing, now) {
|
|
56
|
+
const fields = {};
|
|
57
|
+
for (const [field, value] of Object.entries(record)) {
|
|
58
|
+
fields[field] = { v: value, ts: now };
|
|
59
|
+
}
|
|
60
|
+
if (existing) {
|
|
61
|
+
for (const [field, reg] of Object.entries(existing.fields)) {
|
|
62
|
+
if (!(field in fields)) {
|
|
63
|
+
fields[field] = reg;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { _crdt: "lww-map", fields };
|
|
68
|
+
}
|
|
69
|
+
function buildRgaState(arr, existing, generateNid) {
|
|
70
|
+
const existingByValue = /* @__PURE__ */ new Map();
|
|
71
|
+
if (existing) {
|
|
72
|
+
for (const item of existing.items) {
|
|
73
|
+
const key = JSON.stringify(item.v);
|
|
74
|
+
if (!existingByValue.has(key)) existingByValue.set(key, item);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const usedNids = /* @__PURE__ */ new Set();
|
|
78
|
+
const newItems = [];
|
|
79
|
+
for (const el of arr) {
|
|
80
|
+
const key = JSON.stringify(el);
|
|
81
|
+
const match = existingByValue.get(key);
|
|
82
|
+
if (match && !usedNids.has(match.nid)) {
|
|
83
|
+
newItems.push(match);
|
|
84
|
+
usedNids.add(match.nid);
|
|
85
|
+
} else {
|
|
86
|
+
const nid = generateNid();
|
|
87
|
+
newItems.push({ nid, v: el });
|
|
88
|
+
usedNids.add(nid);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const tombstones = existing ? [...existing.tombstones] : [];
|
|
92
|
+
const extraItems = [];
|
|
93
|
+
if (existing) {
|
|
94
|
+
for (const item of existing.items) {
|
|
95
|
+
if (!usedNids.has(item.nid)) {
|
|
96
|
+
if (!tombstones.includes(item.nid)) tombstones.push(item.nid);
|
|
97
|
+
extraItems.push(item);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const items = [...newItems, ...extraItems];
|
|
102
|
+
return { _crdt: "rga", items, tombstones };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export {
|
|
106
|
+
resolveCrdtSnapshot,
|
|
107
|
+
mergeCrdtStates,
|
|
108
|
+
buildLwwMapState,
|
|
109
|
+
buildRgaState
|
|
110
|
+
};
|
|
111
|
+
//# sourceMappingURL=chunk-J66GRPNH.js.map
|