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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file typed-table.ts
|
|
3
|
+
* @notice High-level Prisma-style API for Web3QL encrypted tables.
|
|
4
|
+
*
|
|
5
|
+
* v1.1 upgrades:
|
|
6
|
+
* • Optional SchemaDefinition support — auto validates, encodes, and decodes
|
|
7
|
+
* fields using the extended type system (TIMESTAMP, UUID, ENUM, DECIMAL, etc.)
|
|
8
|
+
* • NOT NULL + DEFAULT enforcement on write
|
|
9
|
+
* • findMany with full query builder support: where/orderBy/limit/select/distinct
|
|
10
|
+
* • findAll — convenience method (fetches + decrypts all records in batches)
|
|
11
|
+
* • aggregate — COUNT/SUM/AVG/MIN/MAX over filtered decrypted records
|
|
12
|
+
* • seed — bulk insert an array of records
|
|
13
|
+
*
|
|
14
|
+
* Usage (basic, schema-less — identical to v1.0):
|
|
15
|
+
* ─────────────────────────────────────────────────────────────
|
|
16
|
+
* const users = new TypedTableClient<User>('users', db.table('0xTABLE'))
|
|
17
|
+
* await users.create(1n, { id: 1n, name: 'Alice' })
|
|
18
|
+
* const alice = await users.findUnique(1n)
|
|
19
|
+
*
|
|
20
|
+
* Usage (with schema for validation + type coercion):
|
|
21
|
+
* ─────────────────────────────────────────────────────────────
|
|
22
|
+
* const schema: SchemaDefinition = [
|
|
23
|
+
* { name: 'id', type: 'INT', primaryKey: true },
|
|
24
|
+
* { name: 'name', type: 'TEXT', notNull: true },
|
|
25
|
+
* { name: 'email', type: 'TEXT', notNull: true },
|
|
26
|
+
* { name: 'createdAt', type: 'TIMESTAMP', default: () => new Date() },
|
|
27
|
+
* { name: 'role', type: 'ENUM', enumValues: ['user','admin'], default: 'user' },
|
|
28
|
+
* ]
|
|
29
|
+
* const users = new TypedTableClient<User>('users', db.table('0xADDR'), schema)
|
|
30
|
+
*
|
|
31
|
+
* // findMany with query builder
|
|
32
|
+
* const admins = await users.findMany(ownerAddr, {
|
|
33
|
+
* where: [['role', 'eq', 'admin']],
|
|
34
|
+
* orderBy: [['createdAt', 'desc']],
|
|
35
|
+
* limit: 20,
|
|
36
|
+
* select: ['id', 'name', 'email'],
|
|
37
|
+
* })
|
|
38
|
+
* ─────────────────────────────────────────────────────────────
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { ethers } from 'ethers';
|
|
42
|
+
import type { EncryptedTableClient } from './table-client.js';
|
|
43
|
+
import {
|
|
44
|
+
SchemaDefinition,
|
|
45
|
+
validateAndEncode,
|
|
46
|
+
decodeRow,
|
|
47
|
+
} from './types.js';
|
|
48
|
+
import {
|
|
49
|
+
query as buildQuery,
|
|
50
|
+
WhereOperator,
|
|
51
|
+
SortDirection,
|
|
52
|
+
AggregateOptions,
|
|
53
|
+
AggregateResult,
|
|
54
|
+
} from './query.js';
|
|
55
|
+
|
|
56
|
+
export type { SchemaDefinition } from './types.js';
|
|
57
|
+
|
|
58
|
+
// ─────────────────────────────────────────────────────────────
|
|
59
|
+
// Public API types
|
|
60
|
+
// ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** A single WHERE clause expressed as a tuple. */
|
|
63
|
+
export type WhereTuple =
|
|
64
|
+
| [field: string, op: 'isNull' | 'isNotNull']
|
|
65
|
+
| [field: string, op: 'in' | 'notIn', values: unknown[]]
|
|
66
|
+
| [field: string, op: 'between', range: [unknown, unknown]]
|
|
67
|
+
| [field: string, op: WhereOperator, value: unknown];
|
|
68
|
+
|
|
69
|
+
export interface FindManyOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Starting index into the owner's record list when fetching from chain.
|
|
72
|
+
* Applied BEFORE client-side filtering — increase if paginating large tables.
|
|
73
|
+
* Default: 0.
|
|
74
|
+
*/
|
|
75
|
+
chainOffset?: bigint;
|
|
76
|
+
/**
|
|
77
|
+
* Maximum records to fetch from chain per page.
|
|
78
|
+
* Default: 200 (raised from v1.0's 50 to allow client-side filtering).
|
|
79
|
+
*/
|
|
80
|
+
chainLimit?: bigint;
|
|
81
|
+
/** WHERE conditions — ANDed together. Applied after decrypt. */
|
|
82
|
+
where?: WhereTuple[];
|
|
83
|
+
/** Sort order — applied after filtering. */
|
|
84
|
+
orderBy?: [field: string, dir?: SortDirection][];
|
|
85
|
+
/** Max records returned after filtering (client-side LIMIT). */
|
|
86
|
+
limit?: number;
|
|
87
|
+
/** Skip N records after filtering (client-side OFFSET). */
|
|
88
|
+
offset?: number;
|
|
89
|
+
/** Return only these fields. */
|
|
90
|
+
select?: string[];
|
|
91
|
+
/** Deduplicate on this field value. */
|
|
92
|
+
distinct?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RecordWithId<T> {
|
|
96
|
+
/**
|
|
97
|
+
* The uint256 primary key cannot be recovered from the on-chain bytes32 key alone
|
|
98
|
+
* (it is a keccak256 hash). This field is always `0n` when records are fetched via
|
|
99
|
+
* `findMany()`. Store the primary key inside your data payload and read it from
|
|
100
|
+
* `record.data.id` instead.
|
|
101
|
+
*/
|
|
102
|
+
id : bigint;
|
|
103
|
+
/** Decrypted, typed record data. */
|
|
104
|
+
data : T;
|
|
105
|
+
/** bytes32 on-chain key (hex string). */
|
|
106
|
+
recordKey: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────
|
|
110
|
+
// TypedTableClient
|
|
111
|
+
// ─────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export class TypedTableClient<T extends Record<string, unknown>> {
|
|
114
|
+
private tableName : string;
|
|
115
|
+
private inner : EncryptedTableClient;
|
|
116
|
+
private schema? : SchemaDefinition;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param tableName Must match the name used in the SQL schema and createTable().
|
|
120
|
+
* @param inner An EncryptedTableClient from DatabaseClient.table(address).
|
|
121
|
+
* @param schema Optional field descriptors — enables validation, type coercion,
|
|
122
|
+
* NOT NULL enforcement, and DEFAULT values.
|
|
123
|
+
*/
|
|
124
|
+
constructor(tableName: string, inner: EncryptedTableClient, schema?: SchemaDefinition) {
|
|
125
|
+
this.tableName = tableName;
|
|
126
|
+
this.inner = inner;
|
|
127
|
+
this.schema = schema;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Key helper ─────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** Derive the canonical bytes32 on-chain key for a given primary key id. */
|
|
133
|
+
key(id: bigint): string {
|
|
134
|
+
return this.inner.deriveKey(this.tableName, id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Internal encode/decode ──────────────────────────────────
|
|
138
|
+
|
|
139
|
+
private encode(data: T): string {
|
|
140
|
+
if (this.schema) {
|
|
141
|
+
const wire = validateAndEncode(this.schema, data as Record<string, unknown>);
|
|
142
|
+
return JSON.stringify(wire);
|
|
143
|
+
}
|
|
144
|
+
return JSON.stringify(data);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private decode(plaintext: string): T {
|
|
148
|
+
const parsed = JSON.parse(plaintext) as Record<string, unknown>;
|
|
149
|
+
if (this.schema) return decodeRow(this.schema, parsed) as T;
|
|
150
|
+
return parsed as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Write ───────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate, encode, encrypt, and store a new record.
|
|
157
|
+
* Throws if a non-deleted record with the same id already exists.
|
|
158
|
+
*/
|
|
159
|
+
async create(id: bigint, data: T): Promise<ethers.TransactionReceipt> {
|
|
160
|
+
return this.inner.writeRaw(this.key(id), this.encode(data));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Bulk-insert an array of records. Each record must include the primary key field.
|
|
165
|
+
* Records are written sequentially — fails on first error.
|
|
166
|
+
*/
|
|
167
|
+
async seed(
|
|
168
|
+
rows: { id: bigint; data: T }[],
|
|
169
|
+
): Promise<ethers.TransactionReceipt[]> {
|
|
170
|
+
const receipts: ethers.TransactionReceipt[] = [];
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
receipts.push(await this.create(row.id, row.data));
|
|
173
|
+
}
|
|
174
|
+
return receipts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Read ────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read and decrypt a single record by primary key.
|
|
181
|
+
* Returns `null` if the record does not exist or has been deleted.
|
|
182
|
+
*/
|
|
183
|
+
async findUnique(id: bigint): Promise<T | null> {
|
|
184
|
+
try {
|
|
185
|
+
const exists = await this.inner.exists(this.key(id));
|
|
186
|
+
if (!exists) return null;
|
|
187
|
+
const plaintext = await this.inner.readPlaintext(this.key(id));
|
|
188
|
+
return this.decode(plaintext);
|
|
189
|
+
} catch (err: unknown) {
|
|
190
|
+
const msg = (err as Error).message ?? '';
|
|
191
|
+
if (msg.includes('deleted') || msg.includes('not found') || msg.includes('RecordMeta')) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* List and decrypt all records owned by `ownerAddress`, with optional
|
|
200
|
+
* client-side filtering, sorting, pagination, and projection.
|
|
201
|
+
*
|
|
202
|
+
* ⚠ Records are decrypted client-side. Use `chainLimit` to constrain
|
|
203
|
+
* chain reads on large tables. For production scale, use the relay-
|
|
204
|
+
* maintained index endpoint (v1.2) instead.
|
|
205
|
+
*/
|
|
206
|
+
async findMany(
|
|
207
|
+
ownerAddress : string,
|
|
208
|
+
options : FindManyOptions = {},
|
|
209
|
+
): Promise<RecordWithId<Partial<T>>[]> {
|
|
210
|
+
const chainOffset = options.chainOffset ?? 0n;
|
|
211
|
+
const chainLimit = options.chainLimit ?? 200n;
|
|
212
|
+
|
|
213
|
+
const keys: string[] = await this.inner.listOwnerRecords(ownerAddress, chainOffset, chainLimit);
|
|
214
|
+
const raw: RecordWithId<T>[] = [];
|
|
215
|
+
|
|
216
|
+
// Decrypt in parallel (capped at 20 concurrent RPCs)
|
|
217
|
+
const BATCH = 20;
|
|
218
|
+
for (let i = 0; i < keys.length; i += BATCH) {
|
|
219
|
+
const batch = keys.slice(i, i + BATCH);
|
|
220
|
+
const settled = await Promise.allSettled(
|
|
221
|
+
batch.map(async (recordKey) => {
|
|
222
|
+
const plaintext = await this.inner.readPlaintext(recordKey);
|
|
223
|
+
return { id: 0n, data: this.decode(plaintext), recordKey };
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
for (const r of settled) {
|
|
227
|
+
if (r.status === 'fulfilled') raw.push(r.value);
|
|
228
|
+
// silently skip inaccessible / deleted records
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build query
|
|
233
|
+
let q = buildQuery(raw.map((r) => r.data as Record<string, unknown>));
|
|
234
|
+
|
|
235
|
+
if (options.where) {
|
|
236
|
+
for (const clause of options.where) {
|
|
237
|
+
if (clause[1] === 'isNull' || clause[1] === 'isNotNull') {
|
|
238
|
+
q = q.where(clause[0], clause[1]);
|
|
239
|
+
} else if (clause[1] === 'in' || clause[1] === 'notIn') {
|
|
240
|
+
q = q.where(clause[0], clause[1], clause[2] as unknown[]);
|
|
241
|
+
} else if (clause[1] === 'between') {
|
|
242
|
+
q = q.where(clause[0], clause[1], clause[2] as [unknown, unknown]);
|
|
243
|
+
} else {
|
|
244
|
+
q = q.where(clause[0], clause[1] as WhereOperator, clause[2]);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (options.orderBy) {
|
|
249
|
+
for (const [field, dir] of options.orderBy) q = q.orderBy(field, dir ?? 'asc');
|
|
250
|
+
}
|
|
251
|
+
if (options.limit != null) q = q.limit(options.limit);
|
|
252
|
+
if (options.offset != null) q = q.offset(options.offset);
|
|
253
|
+
if (options.select) q = q.select(options.select);
|
|
254
|
+
if (options.distinct) q = q.distinct(options.distinct);
|
|
255
|
+
|
|
256
|
+
const filteredData = q.execute() as Partial<T>[];
|
|
257
|
+
|
|
258
|
+
// Remap back to RecordWithId — match by index since we decrypted in order
|
|
259
|
+
return filteredData.map((data, idx) => ({
|
|
260
|
+
id : 0n,
|
|
261
|
+
data,
|
|
262
|
+
recordKey: raw[idx]?.recordKey ?? '',
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Convenience: fetch and decrypt ALL records the wallet owns (no chain limit).
|
|
268
|
+
* Useful for small tables or full exports. Decrypts in parallel batches of 20.
|
|
269
|
+
*/
|
|
270
|
+
async findAll(ownerAddress: string): Promise<RecordWithId<T>[]> {
|
|
271
|
+
const total = Number(await this.inner.ownerRecordCount(ownerAddress));
|
|
272
|
+
return this.findMany(ownerAddress, {
|
|
273
|
+
chainOffset: 0n,
|
|
274
|
+
chainLimit : BigInt(total),
|
|
275
|
+
}) as Promise<RecordWithId<T>[]>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Aggregate over owner's records: COUNT, SUM, AVG, MIN, MAX, GROUP BY.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* await users.aggregate(ownerAddress, { count: '*', groupBy: 'role' })
|
|
283
|
+
* // => [{ group: 'admin', count: 3 }, { group: 'user', count: 47 }]
|
|
284
|
+
*/
|
|
285
|
+
async aggregate(
|
|
286
|
+
ownerAddress : string,
|
|
287
|
+
opts : AggregateOptions,
|
|
288
|
+
where? : WhereTuple[],
|
|
289
|
+
chainLimit? : bigint,
|
|
290
|
+
): Promise<AggregateResult[]> {
|
|
291
|
+
const records = await this.findMany(ownerAddress, {
|
|
292
|
+
chainLimit,
|
|
293
|
+
where,
|
|
294
|
+
});
|
|
295
|
+
const rows = records.map((r) => r.data as Record<string, unknown>);
|
|
296
|
+
return buildQuery(rows).aggregate(opts);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Update ──────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Fetch existing data, merge with `patch`, re-encrypt, and update on-chain.
|
|
303
|
+
*/
|
|
304
|
+
async update(id: bigint, patch: Partial<T>): Promise<ethers.TransactionReceipt> {
|
|
305
|
+
const current = await this.findUnique(id);
|
|
306
|
+
if (current === null) throw new Error(`TypedTableClient.update: record ${id} not found`);
|
|
307
|
+
const merged = { ...current, ...patch } as T;
|
|
308
|
+
return this.inner.updateRaw(this.key(id), this.encode(merged));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Replace a record's data entirely (no merge).
|
|
313
|
+
* More gas-efficient when you have the full new payload ready.
|
|
314
|
+
*/
|
|
315
|
+
async replace(id: bigint, data: T): Promise<ethers.TransactionReceipt> {
|
|
316
|
+
return this.inner.updateRaw(this.key(id), this.encode(data));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Delete ──────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Soft-delete a record. The symmetric key is scrubbed for all collaborators.
|
|
323
|
+
* Only the record owner can call this.
|
|
324
|
+
*/
|
|
325
|
+
async remove(id: bigint): Promise<ethers.TransactionReceipt> {
|
|
326
|
+
return this.inner.deleteRecord(this.key(id));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/** Total number of records ever written by `ownerAddress` (including deleted). */
|
|
332
|
+
async count(ownerAddress: string): Promise<bigint> {
|
|
333
|
+
return this.inner.ownerRecordCount(ownerAddress);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** True if a live (non-deleted) record exists for the given id. */
|
|
337
|
+
async exists(id: bigint): Promise<boolean> {
|
|
338
|
+
return this.inner.exists(this.key(id));
|
|
339
|
+
}
|
|
340
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file types.ts
|
|
3
|
+
* @notice Web3QL v1.1 — extended type system with serialization, validation,
|
|
4
|
+
* DEFAULT/NOT NULL enforcement, and codec helpers.
|
|
5
|
+
*
|
|
6
|
+
* Every type maps: JS value ↔ JSON-storable "wire" representation.
|
|
7
|
+
* The chain stores the JSON string; this module handles encode/decode.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────
|
|
11
|
+
// Field type literal union
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type FieldType =
|
|
15
|
+
| 'INT' // bigint ↔ decimal string (int256)
|
|
16
|
+
| 'BIGINT' // bigint ↔ decimal string (uint256)
|
|
17
|
+
| 'UINT8' // number (0–255) ↔ number
|
|
18
|
+
| 'UINT16' // number (0–65535) ↔ number
|
|
19
|
+
| 'UINT32' // number (0–4294967295) ↔ number
|
|
20
|
+
| 'UINT64' // bigint ↔ decimal string
|
|
21
|
+
| 'TEXT' // string ↔ string
|
|
22
|
+
| 'BOOL' // boolean ↔ boolean
|
|
23
|
+
| 'FLOAT' // number ↔ number (stored scaled × 1e6 as integer)
|
|
24
|
+
| 'ADDRESS' // string (0x…) ↔ string, lowercased
|
|
25
|
+
| 'TIMESTAMP' // Date ↔ number (unix ms)
|
|
26
|
+
| 'DATE' // Date ↔ number (unix ms of midnight UTC)
|
|
27
|
+
| 'UUID' // string (36 chars) ↔ string
|
|
28
|
+
| 'BYTES32' // string (0x-prefixed, 66 chars) ↔ string
|
|
29
|
+
| 'JSON' // unknown ↔ unknown (validated as parseable JSON)
|
|
30
|
+
| 'JSONB' // alias for JSON — schema-less document storage
|
|
31
|
+
| 'ENUM' // string (label) ↔ number (index)
|
|
32
|
+
| 'DECIMAL'; // number ↔ string (exact decimal, no float rounding)
|
|
33
|
+
|
|
34
|
+
/** Sentinel value stored on-chain when a nullable field has no value. */
|
|
35
|
+
export const NULL_SENTINEL = '__NULL__';
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
38
|
+
// Field descriptor
|
|
39
|
+
// ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface FieldDescriptor {
|
|
42
|
+
name : string;
|
|
43
|
+
type : FieldType;
|
|
44
|
+
primaryKey?: boolean;
|
|
45
|
+
notNull? : boolean;
|
|
46
|
+
/** Static default value OR factory function called on each write */
|
|
47
|
+
default? : unknown | (() => unknown);
|
|
48
|
+
/** ENUM: ordered list of string labels */
|
|
49
|
+
enumValues?: string[];
|
|
50
|
+
/** DECIMAL: [totalDigits, decimalPlaces] */
|
|
51
|
+
precision? : [number, number];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SchemaDefinition = FieldDescriptor[];
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────
|
|
57
|
+
// Type codecs (encode: JS → wire, decode: wire → JS)
|
|
58
|
+
// ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function encodeInt(v: unknown): number | string {
|
|
61
|
+
if (typeof v === 'bigint') return v.toString();
|
|
62
|
+
if (typeof v === 'number') return Math.trunc(v).toString();
|
|
63
|
+
return String(v);
|
|
64
|
+
}
|
|
65
|
+
function decodeInt(v: unknown): bigint { return BigInt(String(v)); }
|
|
66
|
+
|
|
67
|
+
function encodeFloat(v: unknown): number {
|
|
68
|
+
const n = Number(v);
|
|
69
|
+
if (!isFinite(n)) throw new TypeError(`FLOAT: expected finite number, got ${v}`);
|
|
70
|
+
// Store as scaled integer to avoid float drift
|
|
71
|
+
return Math.round(n * 1_000_000);
|
|
72
|
+
}
|
|
73
|
+
function decodeFloat(v: unknown): number { return Number(v) / 1_000_000; }
|
|
74
|
+
|
|
75
|
+
function encodeTimestamp(v: unknown): number {
|
|
76
|
+
if (v instanceof Date) return v.getTime();
|
|
77
|
+
if (typeof v === 'number') return v;
|
|
78
|
+
if (typeof v === 'string') return new Date(v).getTime();
|
|
79
|
+
throw new TypeError(`TIMESTAMP: expected Date or number, got ${typeof v}`);
|
|
80
|
+
}
|
|
81
|
+
function decodeTimestamp(v: unknown): Date { return new Date(Number(v)); }
|
|
82
|
+
|
|
83
|
+
function encodeDate(v: unknown): number {
|
|
84
|
+
const ms = encodeTimestamp(v);
|
|
85
|
+
// Truncate to midnight UTC
|
|
86
|
+
return ms - (ms % 86_400_000);
|
|
87
|
+
}
|
|
88
|
+
function decodeDate(v: unknown): Date {
|
|
89
|
+
const d = new Date(Number(v));
|
|
90
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
91
|
+
return d;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
95
|
+
function encodeUUID(v: unknown): string {
|
|
96
|
+
const s = String(v).toLowerCase();
|
|
97
|
+
if (!UUID_RE.test(s)) throw new TypeError(`UUID: invalid format "${s}"`);
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const BYTES32_RE = /^0x[0-9a-f]{64}$/i;
|
|
102
|
+
function encodeBytes32(v: unknown): string {
|
|
103
|
+
const s = String(v).toLowerCase();
|
|
104
|
+
if (!BYTES32_RE.test(s)) throw new TypeError(`BYTES32: expected 0x-prefixed 64-hex-char string, got "${s}"`);
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ADDRESS_RE = /^0x[0-9a-f]{40}$/i;
|
|
109
|
+
function encodeAddress(v: unknown): string {
|
|
110
|
+
const s = String(v).toLowerCase();
|
|
111
|
+
if (!ADDRESS_RE.test(s)) throw new TypeError(`ADDRESS: invalid EVM address "${s}"`);
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encodeUint(bits: 8 | 16 | 32, v: unknown): number {
|
|
116
|
+
const n = Number(v);
|
|
117
|
+
const max = bits === 8 ? 255 : bits === 16 ? 65535 : 4294967295;
|
|
118
|
+
if (!Number.isInteger(n) || n < 0 || n > max) {
|
|
119
|
+
throw new RangeError(`UINT${bits}: value ${n} out of range [0, ${max}]`);
|
|
120
|
+
}
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function encodeUint64(v: unknown): string {
|
|
125
|
+
const n = BigInt(String(v));
|
|
126
|
+
if (n < 0n) throw new RangeError(`UINT64: value must be non-negative`);
|
|
127
|
+
return n.toString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function encodeJSON(v: unknown): unknown {
|
|
131
|
+
// Validate it round-trips cleanly
|
|
132
|
+
JSON.parse(JSON.stringify(v));
|
|
133
|
+
return v;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function encodeEnum(field: FieldDescriptor, v: unknown): number {
|
|
137
|
+
const labels = field.enumValues ?? [];
|
|
138
|
+
if (typeof v === 'number') {
|
|
139
|
+
if (v < 0 || v >= labels.length) throw new RangeError(`ENUM: index ${v} out of range [0,${labels.length})`);
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
const idx = labels.indexOf(String(v));
|
|
143
|
+
if (idx === -1) throw new TypeError(`ENUM: "${v}" not in [${labels.join(', ')}]`);
|
|
144
|
+
return idx;
|
|
145
|
+
}
|
|
146
|
+
function decodeEnum(field: FieldDescriptor, v: unknown): string {
|
|
147
|
+
const labels = field.enumValues ?? [];
|
|
148
|
+
const idx = Number(v);
|
|
149
|
+
return labels[idx] ?? String(v);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function encodeDecimal(field: FieldDescriptor, v: unknown): string {
|
|
153
|
+
const n = Number(v);
|
|
154
|
+
if (!isFinite(n)) throw new TypeError(`DECIMAL: expected finite number, got ${v}`);
|
|
155
|
+
const scale = field.precision?.[1] ?? 2;
|
|
156
|
+
return n.toFixed(scale);
|
|
157
|
+
}
|
|
158
|
+
function decodeDecimal(v: unknown): number { return parseFloat(String(v)); }
|
|
159
|
+
|
|
160
|
+
// ─────────────────────────────────────────────────────────────
|
|
161
|
+
// Public encode / decode helpers
|
|
162
|
+
// ─────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** Encode a JS value → wire format (to be JSON-stringified and stored). */
|
|
165
|
+
export function encodeFieldValue(field: FieldDescriptor, value: unknown): unknown {
|
|
166
|
+
switch (field.type) {
|
|
167
|
+
case 'INT':
|
|
168
|
+
case 'BIGINT': return encodeInt(value);
|
|
169
|
+
case 'UINT8': return encodeUint(8, value);
|
|
170
|
+
case 'UINT16': return encodeUint(16, value);
|
|
171
|
+
case 'UINT32': return encodeUint(32, value);
|
|
172
|
+
case 'UINT64': return encodeUint64(value);
|
|
173
|
+
case 'TEXT': return String(value);
|
|
174
|
+
case 'BOOL': return Boolean(value);
|
|
175
|
+
case 'FLOAT': return encodeFloat(value);
|
|
176
|
+
case 'ADDRESS': return encodeAddress(value);
|
|
177
|
+
case 'TIMESTAMP': return encodeTimestamp(value);
|
|
178
|
+
case 'DATE': return encodeDate(value);
|
|
179
|
+
case 'UUID': return encodeUUID(value);
|
|
180
|
+
case 'BYTES32': return encodeBytes32(value);
|
|
181
|
+
case 'JSON':
|
|
182
|
+
case 'JSONB': return encodeJSON(value);
|
|
183
|
+
case 'ENUM': return encodeEnum(field, value);
|
|
184
|
+
case 'DECIMAL': return encodeDecimal(field, value);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Decode a wire-format value → typed JS value. */
|
|
189
|
+
export function decodeFieldValue(field: FieldDescriptor, value: unknown): unknown {
|
|
190
|
+
if (value === NULL_SENTINEL) return null;
|
|
191
|
+
if (value === null || value === undefined) return value;
|
|
192
|
+
switch (field.type) {
|
|
193
|
+
case 'INT':
|
|
194
|
+
case 'BIGINT': return decodeInt(value);
|
|
195
|
+
case 'UINT64': return BigInt(String(value));
|
|
196
|
+
case 'UINT8':
|
|
197
|
+
case 'UINT16':
|
|
198
|
+
case 'UINT32': return Number(value);
|
|
199
|
+
case 'TEXT':
|
|
200
|
+
case 'UUID':
|
|
201
|
+
case 'BYTES32':
|
|
202
|
+
case 'ADDRESS': return String(value);
|
|
203
|
+
case 'BOOL': return Boolean(value);
|
|
204
|
+
case 'FLOAT': return decodeFloat(value);
|
|
205
|
+
case 'TIMESTAMP': return decodeTimestamp(value);
|
|
206
|
+
case 'DATE': return decodeDate(value);
|
|
207
|
+
case 'JSON':
|
|
208
|
+
case 'JSONB': return value;
|
|
209
|
+
case 'ENUM': return decodeEnum(field, value);
|
|
210
|
+
case 'DECIMAL': return decodeDecimal(value);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─────────────────────────────────────────────────────────────
|
|
215
|
+
// Row-level validation — NOT NULL + DEFAULT + type coerce
|
|
216
|
+
// ─────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate and normalise a plain JS object before writing to the chain.
|
|
220
|
+
*
|
|
221
|
+
* • Applies DEFAULT values for missing fields
|
|
222
|
+
* • Enforces NOT NULL for fields without a default
|
|
223
|
+
* • Encodes each field to its wire representation
|
|
224
|
+
*
|
|
225
|
+
* @param schema SchemaDefinition for this table
|
|
226
|
+
* @param row Raw user input object
|
|
227
|
+
* @returns Wire-ready object (to be JSON.stringify'd and encrypted)
|
|
228
|
+
*/
|
|
229
|
+
export function validateAndEncode(
|
|
230
|
+
schema: SchemaDefinition,
|
|
231
|
+
row : Record<string, unknown>,
|
|
232
|
+
): Record<string, unknown> {
|
|
233
|
+
const out: Record<string, unknown> = {};
|
|
234
|
+
|
|
235
|
+
for (const field of schema) {
|
|
236
|
+
let value = row[field.name];
|
|
237
|
+
|
|
238
|
+
// Apply default if value is absent
|
|
239
|
+
if (value === undefined || value === null) {
|
|
240
|
+
if (field.default !== undefined) {
|
|
241
|
+
value = typeof field.default === 'function'
|
|
242
|
+
? (field.default as () => unknown)()
|
|
243
|
+
: field.default;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// NOT NULL enforcement
|
|
248
|
+
if ((value === undefined || value === null) && field.notNull) {
|
|
249
|
+
throw new TypeError(
|
|
250
|
+
`Field "${field.name}" is NOT NULL but no value or default was provided.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Skip truly absent optional fields (store as NULL_SENTINEL for type safety)
|
|
255
|
+
if (value === undefined || value === null) {
|
|
256
|
+
out[field.name] = NULL_SENTINEL;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
out[field.name] = encodeFieldValue(field, value);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Decode a wire-format JSON object back to typed JS values.
|
|
268
|
+
*
|
|
269
|
+
* @param schema SchemaDefinition for this table
|
|
270
|
+
* @param wire Object from JSON.parse(plaintext)
|
|
271
|
+
*/
|
|
272
|
+
export function decodeRow(
|
|
273
|
+
schema: SchemaDefinition,
|
|
274
|
+
wire : Record<string, unknown>,
|
|
275
|
+
): Record<string, unknown> {
|
|
276
|
+
const out: Record<string, unknown> = {};
|
|
277
|
+
const byName = new Map(schema.map((f) => [f.name, f]));
|
|
278
|
+
|
|
279
|
+
for (const [key, value] of Object.entries(wire)) {
|
|
280
|
+
const field = byName.get(key);
|
|
281
|
+
out[key] = field ? decodeFieldValue(field, value) : value;
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target" : "ES2022",
|
|
4
|
+
"module" : "Node16",
|
|
5
|
+
"moduleResolution" : "Node16",
|
|
6
|
+
"lib" : ["ES2022"],
|
|
7
|
+
"outDir" : "dist",
|
|
8
|
+
"rootDir" : ".",
|
|
9
|
+
"declaration" : true,
|
|
10
|
+
"declarationMap" : true,
|
|
11
|
+
"sourceMap" : true,
|
|
12
|
+
"esModuleInterop" : true,
|
|
13
|
+
"allowSyntheticDefaultImports": true,
|
|
14
|
+
"resolveJsonModule" : true,
|
|
15
|
+
"strict" : true,
|
|
16
|
+
"noUncheckedIndexedAccess" : true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true,
|
|
18
|
+
"skipLibCheck" : true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|