web3ql-client 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/contracts/PublicKeyRegistry.sol +87 -0
- package/dist/src/access.d.ts +176 -0
- package/dist/src/access.d.ts.map +1 -0
- package/dist/src/access.js +283 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/batch.d.ts +107 -0
- package/dist/src/batch.d.ts.map +1 -0
- package/dist/src/batch.js +188 -0
- package/dist/src/batch.js.map +1 -0
- package/dist/src/cli.d.ts +40 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +361 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/constraints.d.ts +126 -0
- package/dist/src/constraints.d.ts.map +1 -0
- package/dist/src/constraints.js +192 -0
- package/dist/src/constraints.js.map +1 -0
- package/dist/src/crypto.d.ts +118 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +192 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/factory-client.d.ts +106 -0
- package/dist/src/factory-client.d.ts.map +1 -0
- package/dist/src/factory-client.js +202 -0
- package/dist/src/factory-client.js.map +1 -0
- package/dist/src/index-cache.d.ts +156 -0
- package/dist/src/index-cache.d.ts.map +1 -0
- package/dist/src/index-cache.js +265 -0
- package/dist/src/index-cache.js.map +1 -0
- package/dist/src/index.d.ts +60 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +60 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/migrations.d.ts +114 -0
- package/dist/src/migrations.d.ts.map +1 -0
- package/dist/src/migrations.js +173 -0
- package/dist/src/migrations.js.map +1 -0
- package/dist/src/model.d.ts +198 -0
- package/dist/src/model.d.ts.map +1 -0
- package/dist/src/model.js +379 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/query.d.ts +155 -0
- package/dist/src/query.d.ts.map +1 -0
- package/dist/src/query.js +386 -0
- package/dist/src/query.js.map +1 -0
- package/dist/src/registry.d.ts +45 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +80 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/schema-manager.d.ts +109 -0
- package/dist/src/schema-manager.d.ts.map +1 -0
- package/dist/src/schema-manager.js +259 -0
- package/dist/src/schema-manager.js.map +1 -0
- package/dist/src/table-client.d.ts +156 -0
- package/dist/src/table-client.d.ts.map +1 -0
- package/dist/src/table-client.js +292 -0
- package/dist/src/table-client.js.map +1 -0
- package/dist/src/typed-table.d.ts +159 -0
- package/dist/src/typed-table.d.ts.map +1 -0
- package/dist/src/typed-table.js +246 -0
- package/dist/src/typed-table.js.map +1 -0
- package/dist/src/types.d.ts +48 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +222 -0
- package/dist/src/types.js.map +1 -0
- package/keyManager.js +337 -0
- package/package.json +38 -0
- package/src/access.ts +421 -0
- package/src/batch.ts +259 -0
- package/src/cli.ts +349 -0
- package/src/constraints.ts +283 -0
- package/src/crypto.ts +239 -0
- package/src/factory-client.ts +237 -0
- package/src/index-cache.ts +351 -0
- package/src/index.ts +171 -0
- package/src/migrations.ts +215 -0
- package/src/model.ts +538 -0
- package/src/query.ts +508 -0
- package/src/registry.ts +100 -0
- package/src/schema-manager.ts +301 -0
- package/src/table-client.ts +393 -0
- package/src/typed-table.ts +340 -0
- package/src/types.ts +284 -0
- package/tsconfig.json +22 -0
- package/walletUtils.js +204 -0
package/src/query.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file query.ts
|
|
3
|
+
* @notice Web3QL v1.1 — client-side query engine.
|
|
4
|
+
*
|
|
5
|
+
* After decrypting records from the chain, this module provides:
|
|
6
|
+
* • WHERE filtering — eq, ne, gt, gte, lt, lte, in, notIn, like, between, isNull, isNotNull
|
|
7
|
+
* • ORDER BY — multi-column, ASC/DESC
|
|
8
|
+
* • LIMIT / OFFSET — pagination over decrypted records
|
|
9
|
+
* • SELECT projection — pick only specific fields
|
|
10
|
+
* • DISTINCT — deduplicate on a column value
|
|
11
|
+
* • COUNT / SUM / AVG / MIN / MAX / GROUP BY aggregations
|
|
12
|
+
*
|
|
13
|
+
* All operations run in-process on the decrypted plaintext — the chain
|
|
14
|
+
* sees only ciphertext. For large tables, use the relay-maintained index
|
|
15
|
+
* endpoint (v1.2) to avoid decrypting every record.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* ─────────────────────────────────────────────────────────────
|
|
19
|
+
* const results = query(records)
|
|
20
|
+
* .where('age', 'gt', 18n)
|
|
21
|
+
* .where('name', 'like', 'Ali%')
|
|
22
|
+
* .orderBy('age', 'asc')
|
|
23
|
+
* .limit(10)
|
|
24
|
+
* .select(['name', 'age'])
|
|
25
|
+
* .execute();
|
|
26
|
+
*
|
|
27
|
+
* const stats = query(records)
|
|
28
|
+
* .where('active', 'eq', true)
|
|
29
|
+
* .aggregate({ count: '*', avg: 'score', max: 'score' });
|
|
30
|
+
* ─────────────────────────────────────────────────────────────
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────
|
|
34
|
+
// Types
|
|
35
|
+
// ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type Row = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
export type WhereOperator =
|
|
40
|
+
| 'eq' | 'ne'
|
|
41
|
+
| 'gt' | 'gte' | 'lt' | 'lte'
|
|
42
|
+
| 'in' | 'notIn'
|
|
43
|
+
| 'like' | 'ilike'
|
|
44
|
+
| 'between'
|
|
45
|
+
| 'isNull' | 'isNotNull';
|
|
46
|
+
|
|
47
|
+
export interface WhereClause {
|
|
48
|
+
field : string;
|
|
49
|
+
op : WhereOperator;
|
|
50
|
+
/** Single value for eq/ne/gt/gte/lt/lte/like/ilike/isNull/isNotNull */
|
|
51
|
+
value? : unknown;
|
|
52
|
+
/** Array for `in` and `notIn` */
|
|
53
|
+
values? : unknown[];
|
|
54
|
+
/** Two-element tuple for `between` */
|
|
55
|
+
range? : [unknown, unknown];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SortDirection = 'asc' | 'desc';
|
|
59
|
+
|
|
60
|
+
export interface OrderByClause {
|
|
61
|
+
field : string;
|
|
62
|
+
direction: SortDirection;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AggregateOptions {
|
|
66
|
+
count?: '*' | string;
|
|
67
|
+
sum? : string;
|
|
68
|
+
avg? : string;
|
|
69
|
+
min? : string;
|
|
70
|
+
max? : string;
|
|
71
|
+
groupBy?: string;
|
|
72
|
+
/** Time-bucketing: group timestamps by 'minute'|'hour'|'day'|'week'|'month'|'year' */
|
|
73
|
+
timeBucket?: { field: string; unit: TimeBucketUnit };
|
|
74
|
+
/** HAVING — filter on aggregated values (applied after aggregation) */
|
|
75
|
+
having?: HavingClause[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TimeBucketUnit = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
|
79
|
+
|
|
80
|
+
export interface HavingClause {
|
|
81
|
+
/** 'count'|'sum'|'avg'|'min'|'max' */
|
|
82
|
+
aggregate: 'count' | 'sum' | 'avg';
|
|
83
|
+
op : 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
84
|
+
value : number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AggregateResult {
|
|
88
|
+
group?: unknown;
|
|
89
|
+
count?: number;
|
|
90
|
+
sum? : number;
|
|
91
|
+
avg? : number;
|
|
92
|
+
min? : unknown;
|
|
93
|
+
max? : unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─────────────────────────────────────────────────────────────
|
|
97
|
+
// JOIN types
|
|
98
|
+
// ─────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export type JoinType = 'inner' | 'left' | 'right';
|
|
101
|
+
|
|
102
|
+
export interface JoinClause {
|
|
103
|
+
type : JoinType;
|
|
104
|
+
right : Row[];
|
|
105
|
+
on : { left: string; right: string };
|
|
106
|
+
/** Prefix added to right-side fields to avoid collision. Default: 'j_' */
|
|
107
|
+
prefix? : string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────
|
|
111
|
+
// Time-bucket helper
|
|
112
|
+
// ─────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function truncateTimestamp(ms: number, unit: TimeBucketUnit): number {
|
|
115
|
+
const d = new Date(ms);
|
|
116
|
+
switch (unit) {
|
|
117
|
+
case 'minute':
|
|
118
|
+
d.setUTCSeconds(0, 0);
|
|
119
|
+
break;
|
|
120
|
+
case 'hour':
|
|
121
|
+
d.setUTCMinutes(0, 0, 0);
|
|
122
|
+
break;
|
|
123
|
+
case 'day':
|
|
124
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
125
|
+
break;
|
|
126
|
+
case 'week': {
|
|
127
|
+
const dow = d.getUTCDay(); // 0=Sun
|
|
128
|
+
d.setUTCDate(d.getUTCDate() - dow);
|
|
129
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'month':
|
|
133
|
+
d.setUTCDate(1);
|
|
134
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
135
|
+
break;
|
|
136
|
+
case 'year':
|
|
137
|
+
d.setUTCMonth(0, 1);
|
|
138
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return d.getTime();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─────────────────────────────────────────────────────────────
|
|
145
|
+
// HAVING filter
|
|
146
|
+
// ─────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function applyHaving(result: AggregateResult, having: HavingClause[]): boolean {
|
|
149
|
+
for (const h of having) {
|
|
150
|
+
const val = result[h.aggregate] ?? 0;
|
|
151
|
+
switch (h.op) {
|
|
152
|
+
case 'eq': if (val !== h.value) return false; break;
|
|
153
|
+
case 'ne': if (val === h.value) return false; break;
|
|
154
|
+
case 'gt': if (val <= h.value) return false; break;
|
|
155
|
+
case 'gte': if (val < h.value) return false; break;
|
|
156
|
+
case 'lt': if (val >= h.value) return false; break;
|
|
157
|
+
case 'lte': if (val > h.value) return false; break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─────────────────────────────────────────────────────────────
|
|
164
|
+
// Predicate evaluator
|
|
165
|
+
// ─────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function likeToRegex(pattern: string, caseInsensitive: boolean): RegExp {
|
|
168
|
+
const escaped = pattern
|
|
169
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
170
|
+
.replace(/%/g, '.*')
|
|
171
|
+
.replace(/_/g, '.');
|
|
172
|
+
return new RegExp(`^${escaped}$`, caseInsensitive ? 'i' : '');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function compare(a: unknown, b: unknown): number {
|
|
176
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
177
|
+
if (typeof a === 'bigint' && typeof b === 'bigint') return a < b ? -1 : a > b ? 1 : 0;
|
|
178
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
179
|
+
return String(a).localeCompare(String(b));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function applyWhere(row: Row, clause: WhereClause): boolean {
|
|
183
|
+
const rawVal = row[clause.field];
|
|
184
|
+
|
|
185
|
+
switch (clause.op) {
|
|
186
|
+
case 'eq': return rawVal === clause.value;
|
|
187
|
+
case 'ne': return rawVal !== clause.value;
|
|
188
|
+
case 'gt': return compare(rawVal, clause.value) > 0;
|
|
189
|
+
case 'gte': return compare(rawVal, clause.value) >= 0;
|
|
190
|
+
case 'lt': return compare(rawVal, clause.value) < 0;
|
|
191
|
+
case 'lte': return compare(rawVal, clause.value) <= 0;
|
|
192
|
+
case 'in': return (clause.values ?? []).includes(rawVal);
|
|
193
|
+
case 'notIn': return !(clause.values ?? []).includes(rawVal);
|
|
194
|
+
case 'like': return likeToRegex(String(clause.value), false).test(String(rawVal));
|
|
195
|
+
case 'ilike': return likeToRegex(String(clause.value), true).test(String(rawVal));
|
|
196
|
+
case 'between': {
|
|
197
|
+
const [lo, hi] = clause.range!;
|
|
198
|
+
return compare(rawVal, lo) >= 0 && compare(rawVal, hi) <= 0;
|
|
199
|
+
}
|
|
200
|
+
case 'isNull': return rawVal === null || rawVal === undefined;
|
|
201
|
+
case 'isNotNull': return rawVal !== null && rawVal !== undefined;
|
|
202
|
+
default: return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─────────────────────────────────────────────────────────────
|
|
207
|
+
// QueryBuilder
|
|
208
|
+
// ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
export class QueryBuilder<T extends Row> {
|
|
211
|
+
private _rows : T[];
|
|
212
|
+
private _wheres : WhereClause[] = [];
|
|
213
|
+
private _orders : OrderByClause[] = [];
|
|
214
|
+
private _limitN? : number;
|
|
215
|
+
private _offsetN?: number;
|
|
216
|
+
private _fields? : string[];
|
|
217
|
+
private _distinct?: string;
|
|
218
|
+
private _joins : JoinClause[] = [];
|
|
219
|
+
|
|
220
|
+
constructor(rows: T[]) {
|
|
221
|
+
this._rows = rows;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Filtering ───────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Add a WHERE condition. Multiple calls are ANDed together.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* .where('age', 'gt', 18n)
|
|
231
|
+
* .where('status', 'in', undefined, ['active', 'pending'])
|
|
232
|
+
* .where('score', 'between', undefined, undefined, [0, 100])
|
|
233
|
+
* .where('deletedAt', 'isNull')
|
|
234
|
+
*/
|
|
235
|
+
where(field: string, op: 'isNull' | 'isNotNull') : this;
|
|
236
|
+
where(field: string, op: 'in' | 'notIn', values: unknown[]) : this;
|
|
237
|
+
where(field: string, op: 'between', range: [unknown, unknown]) : this;
|
|
238
|
+
where(field: string, op: WhereOperator, value?: unknown) : this;
|
|
239
|
+
where(
|
|
240
|
+
field : string,
|
|
241
|
+
op : WhereOperator,
|
|
242
|
+
valueOrArr?: unknown,
|
|
243
|
+
_unused2?: unknown,
|
|
244
|
+
): this {
|
|
245
|
+
if (op === 'isNull' || op === 'isNotNull') {
|
|
246
|
+
this._wheres.push({ field, op });
|
|
247
|
+
} else if (op === 'in' || op === 'notIn') {
|
|
248
|
+
this._wheres.push({ field, op, values: valueOrArr as unknown[] });
|
|
249
|
+
} else if (op === 'between') {
|
|
250
|
+
this._wheres.push({ field, op, range: valueOrArr as [unknown, unknown] });
|
|
251
|
+
} else {
|
|
252
|
+
this._wheres.push({ field, op, value: valueOrArr });
|
|
253
|
+
}
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Sorting ─────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/** Add an ORDER BY clause. Multiple calls are applied in sequence. */
|
|
260
|
+
orderBy(field: string, direction: SortDirection = 'asc'): this {
|
|
261
|
+
this._orders.push({ field, direction });
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Pagination ──────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
limit(n: number): this { this._limitN = n; return this; }
|
|
268
|
+
offset(n: number): this { this._offsetN = n; return this; }
|
|
269
|
+
|
|
270
|
+
// ── Projection ──────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/** Return only the specified fields from each row. */
|
|
273
|
+
select(fields: string[]): this { this._fields = fields; return this; }
|
|
274
|
+
|
|
275
|
+
/** Deduplicate rows where `field` has the same value. */
|
|
276
|
+
distinct(field: string): this { this._distinct = field; return this; }
|
|
277
|
+
|
|
278
|
+
// ── JOIN ────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Join this table with a `right` array on matching key columns.
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* query(orders)
|
|
285
|
+
* .join('inner', users, { left: 'userId', right: 'id' })
|
|
286
|
+
* .execute()
|
|
287
|
+
* // Each matched row = order fields + user fields prefixed with 'j_'
|
|
288
|
+
*
|
|
289
|
+
* query(orders)
|
|
290
|
+
* .join('left', users, { left: 'userId', right: 'id' }, 'user_')
|
|
291
|
+
* .select(['id', 'user_name', 'amount'])
|
|
292
|
+
* .execute()
|
|
293
|
+
*/
|
|
294
|
+
join(
|
|
295
|
+
type : JoinType,
|
|
296
|
+
right : Row[],
|
|
297
|
+
on : { left: string; right: string },
|
|
298
|
+
prefix: string = 'j_',
|
|
299
|
+
): this {
|
|
300
|
+
this._joins.push({ type, right, on, prefix });
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Terminal: execute ───────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
execute(): Partial<T>[] {
|
|
307
|
+
// 0. Apply JOINs
|
|
308
|
+
let rows: Row[] = [...this._rows];
|
|
309
|
+
for (const j of this._joins) {
|
|
310
|
+
rows = applyJoin(rows, j);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 1. Filter
|
|
314
|
+
rows = this._wheres.length
|
|
315
|
+
? rows.filter((r) => this._wheres.every((w) => applyWhere(r, w)))
|
|
316
|
+
: rows;
|
|
317
|
+
|
|
318
|
+
// 2. Distinct
|
|
319
|
+
if (this._distinct) {
|
|
320
|
+
const seen = new Set<unknown>();
|
|
321
|
+
const field = this._distinct;
|
|
322
|
+
rows = rows.filter((r) => {
|
|
323
|
+
const v = r[field];
|
|
324
|
+
if (seen.has(v)) return false;
|
|
325
|
+
seen.add(v);
|
|
326
|
+
return true;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 3. Sort
|
|
331
|
+
if (this._orders.length) {
|
|
332
|
+
rows.sort((a, b) => {
|
|
333
|
+
for (const ord of this._orders) {
|
|
334
|
+
const cmp = compare(a[ord.field], b[ord.field]);
|
|
335
|
+
if (cmp !== 0) return ord.direction === 'asc' ? cmp : -cmp;
|
|
336
|
+
}
|
|
337
|
+
return 0;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 4. Offset + Limit
|
|
342
|
+
const start = this._offsetN ?? 0;
|
|
343
|
+
const end = this._limitN != null ? start + this._limitN : undefined;
|
|
344
|
+
rows = rows.slice(start, end);
|
|
345
|
+
|
|
346
|
+
// 5. Projection
|
|
347
|
+
if (this._fields) {
|
|
348
|
+
const fields = this._fields;
|
|
349
|
+
return rows.map((r) => {
|
|
350
|
+
const out: Row = {};
|
|
351
|
+
for (const f of fields) out[f] = r[f];
|
|
352
|
+
return out as Partial<T>;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return rows as Partial<T>[];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Terminal: aggregate ─────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Run aggregation functions over filtered (but not sorted/limited) rows.
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* query(rows).where('active', 'eq', true).aggregate({ count: '*', avg: 'score' })
|
|
366
|
+
* // => [{ count: 42, avg: 78.5 }]
|
|
367
|
+
*
|
|
368
|
+
* query(rows).aggregate({ count: '*', groupBy: 'status' })
|
|
369
|
+
* // => [{ group: 'active', count: 30 }, { group: 'inactive', count: 12 }]
|
|
370
|
+
*/
|
|
371
|
+
aggregate(opts: AggregateOptions): AggregateResult[] {
|
|
372
|
+
// Apply JOINs first
|
|
373
|
+
let allRows: Row[] = [...this._rows];
|
|
374
|
+
for (const j of this._joins) allRows = applyJoin(allRows, j);
|
|
375
|
+
|
|
376
|
+
const filtered: Row[] = this._wheres.length
|
|
377
|
+
? allRows.filter((r) => this._wheres.every((w) => applyWhere(r, w)))
|
|
378
|
+
: allRows;
|
|
379
|
+
|
|
380
|
+
// Determine grouping key
|
|
381
|
+
const getGroupKey = (row: Row): unknown => {
|
|
382
|
+
if (opts.timeBucket) {
|
|
383
|
+
const rawMs = Number(row[opts.timeBucket.field]);
|
|
384
|
+
return truncateTimestamp(rawMs, opts.timeBucket.unit);
|
|
385
|
+
}
|
|
386
|
+
if (opts.groupBy) return row[opts.groupBy];
|
|
387
|
+
return '__all__';
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const groups = new Map<unknown, Row[]>();
|
|
391
|
+
for (const row of filtered) {
|
|
392
|
+
const gv = getGroupKey(row);
|
|
393
|
+
const bucket = groups.get(gv);
|
|
394
|
+
if (bucket) bucket.push(row);
|
|
395
|
+
else groups.set(gv, [row]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const noGroupBy = !opts.groupBy && !opts.timeBucket;
|
|
399
|
+
if (noGroupBy) {
|
|
400
|
+
return [this._aggregateGroup('__all__', filtered, opts, noGroupBy)];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const results = Array.from(groups.entries()).map(([groupVal, rows]) =>
|
|
404
|
+
this._aggregateGroup(groupVal, rows, opts, false),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Apply HAVING filter
|
|
408
|
+
if (opts.having?.length) {
|
|
409
|
+
return results.filter((r) => applyHaving(r, opts.having!));
|
|
410
|
+
}
|
|
411
|
+
return results;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private _aggregateGroup(
|
|
415
|
+
groupVal: unknown,
|
|
416
|
+
rows : Row[],
|
|
417
|
+
opts : AggregateOptions,
|
|
418
|
+
omitGroup: boolean,
|
|
419
|
+
): AggregateResult {
|
|
420
|
+
const result: AggregateResult = {};
|
|
421
|
+
if (!omitGroup) result.group = groupVal;
|
|
422
|
+
|
|
423
|
+
if (opts.count != null) {
|
|
424
|
+
result.count = rows.length;
|
|
425
|
+
}
|
|
426
|
+
if (opts.sum) {
|
|
427
|
+
result.sum = rows.reduce((acc, r) => acc + Number(r[opts.sum!] ?? 0), 0);
|
|
428
|
+
}
|
|
429
|
+
if (opts.avg) {
|
|
430
|
+
result.avg = rows.length
|
|
431
|
+
? rows.reduce((acc, r) => acc + Number(r[opts.avg!] ?? 0), 0) / rows.length
|
|
432
|
+
: 0;
|
|
433
|
+
}
|
|
434
|
+
if (opts.min) {
|
|
435
|
+
const vals = rows.map((r) => r[opts.min!]).filter((v) => v != null);
|
|
436
|
+
result.min = vals.reduce<unknown>((a, b) => (compare(a, b) <= 0 ? a : b), vals[0]);
|
|
437
|
+
}
|
|
438
|
+
if (opts.max) {
|
|
439
|
+
const vals = rows.map((r) => r[opts.max!]).filter((v) => v != null);
|
|
440
|
+
result.max = vals.reduce<unknown>((a, b) => (compare(a, b) >= 0 ? a : b), vals[0]);
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─────────────────────────────────────────────────────────────
|
|
447
|
+
// JOIN implementation
|
|
448
|
+
// ─────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
function applyJoin(left: Row[], j: JoinClause): Row[] {
|
|
451
|
+
const prefix = j.prefix ?? 'j_';
|
|
452
|
+
const rightIndex = new Map<unknown, Row[]>();
|
|
453
|
+
for (const r of j.right) {
|
|
454
|
+
const k = r[j.on.right];
|
|
455
|
+
const bucket = rightIndex.get(k);
|
|
456
|
+
if (bucket) bucket.push(r);
|
|
457
|
+
else rightIndex.set(k, [r]);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const result: Row[] = [];
|
|
461
|
+
|
|
462
|
+
for (const l of left) {
|
|
463
|
+
const k = l[j.on.left];
|
|
464
|
+
const matches = rightIndex.get(k);
|
|
465
|
+
|
|
466
|
+
if (matches && matches.length > 0) {
|
|
467
|
+
// INNER or LEFT: emit for each match
|
|
468
|
+
for (const r of matches) {
|
|
469
|
+
const merged: Row = { ...l };
|
|
470
|
+
for (const [rk, rv] of Object.entries(r)) {
|
|
471
|
+
merged[`${prefix}${rk}`] = rv;
|
|
472
|
+
}
|
|
473
|
+
result.push(merged);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
// LEFT JOIN: emit left row with nulled right fields
|
|
477
|
+
if (j.type === 'left') {
|
|
478
|
+
result.push({ ...l });
|
|
479
|
+
}
|
|
480
|
+
// INNER JOIN: no match → skip
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// RIGHT JOIN: also emit right rows with no match on left
|
|
485
|
+
if (j.type === 'right') {
|
|
486
|
+
const leftKeys = new Set(left.map((l) => l[j.on.left]));
|
|
487
|
+
for (const r of j.right) {
|
|
488
|
+
if (!leftKeys.has(r[j.on.right])) {
|
|
489
|
+
const merged: Row = {};
|
|
490
|
+
for (const [rk, rv] of Object.entries(r)) {
|
|
491
|
+
merged[`${prefix}${rk}`] = rv;
|
|
492
|
+
}
|
|
493
|
+
result.push(merged);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ─────────────────────────────────────────────────────────────
|
|
502
|
+
// Factory helper
|
|
503
|
+
// ─────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/** Fluent entry point: `query(records).where(...).orderBy(...).execute()` */
|
|
506
|
+
export function query<T extends Row>(rows: T[]): QueryBuilder<T> {
|
|
507
|
+
return new QueryBuilder(rows);
|
|
508
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file registry.ts
|
|
3
|
+
* @notice Client for the on-chain PublicKeyRegistry contract.
|
|
4
|
+
*
|
|
5
|
+
* Why is a registry needed?
|
|
6
|
+
* ─────────────────────────────────────────────────────────────
|
|
7
|
+
* When the owner wants to share a record with Alice, they need
|
|
8
|
+
* Alice's X25519 public key to encrypt the symmetric key for her.
|
|
9
|
+
* The registry is a simple on-chain mapping (address → bytes32)
|
|
10
|
+
* where each user registers their own encryption public key once,
|
|
11
|
+
* paying only ~40k gas. After that anyone can look it up.
|
|
12
|
+
*
|
|
13
|
+
* Security note:
|
|
14
|
+
* The public key stored here is the X25519 key DERIVED from the
|
|
15
|
+
* Ethereum private key (sha256(ethPrivKey) → nacl keypair).
|
|
16
|
+
* It does NOT expose the Ethereum private key in any way.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ethers } from 'ethers';
|
|
20
|
+
import { publicKeyToHex, hexToPublicKey } from './crypto.js';
|
|
21
|
+
import type { EncryptionKeypair } from './crypto.js';
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────
|
|
24
|
+
// ABI — only what we need
|
|
25
|
+
// ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const REGISTRY_ABI = [
|
|
28
|
+
'function register(bytes32 pubKey) external',
|
|
29
|
+
'function getKey(address user) external view returns (bytes32)',
|
|
30
|
+
'function hasKey(address user) external view returns (bool)',
|
|
31
|
+
'event KeyRegistered(address indexed user, bytes32 publicKey)',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────
|
|
35
|
+
// Client
|
|
36
|
+
// ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export class PublicKeyRegistryClient {
|
|
39
|
+
private contract: ethers.Contract;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
registryAddress : string,
|
|
43
|
+
signerOrProvider : ethers.Signer | ethers.Provider,
|
|
44
|
+
) {
|
|
45
|
+
this.contract = new ethers.Contract(
|
|
46
|
+
registryAddress,
|
|
47
|
+
REGISTRY_ABI,
|
|
48
|
+
signerOrProvider,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Register the caller's encryption public key on-chain.
|
|
54
|
+
* Call this once per wallet — ~40k gas on Celo (~$0.001).
|
|
55
|
+
*
|
|
56
|
+
* @param keypair Your EncryptionKeypair from deriveKeypairFromWallet(signer).
|
|
57
|
+
*/
|
|
58
|
+
async register(keypair: EncryptionKeypair): Promise<ethers.TransactionReceipt> {
|
|
59
|
+
const hex = publicKeyToHex(keypair.publicKey);
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
const tx = await (this.contract as any).register(hex) as { wait(): Promise<ethers.TransactionReceipt> };
|
|
62
|
+
return tx.wait();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check whether an address has registered a public key.
|
|
67
|
+
*/
|
|
68
|
+
async hasKey(address: string): Promise<boolean> {
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
return (this.contract as any).hasKey(address) as Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the X25519 public key for an address.
|
|
75
|
+
* Throws if the address has not registered.
|
|
76
|
+
*/
|
|
77
|
+
async getPublicKey(address: string): Promise<Uint8Array> {
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
const hex = await (this.contract as any).getKey(address) as string;
|
|
80
|
+
return hexToPublicKey(hex);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get public keys for multiple addresses in a single batch of calls.
|
|
85
|
+
* Returns `null` for any address that has not registered.
|
|
86
|
+
*/
|
|
87
|
+
async getPublicKeys(
|
|
88
|
+
addresses: string[],
|
|
89
|
+
): Promise<(Uint8Array | null)[]> {
|
|
90
|
+
return Promise.all(
|
|
91
|
+
addresses.map(async (addr) => {
|
|
92
|
+
try {
|
|
93
|
+
return await this.getPublicKey(addr);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|