@isaijs/core 0.1.3 → 0.1.5
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/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +2 -2
- package/dist/case-DpXlJCL9.js +63 -0
- package/dist/cli/index.js +10 -6
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +119 -5
- package/dist/{create-auth-Ci0PaBoX.js → create-auth-BHdX68Ng.js} +3 -3
- package/dist/{data-layer-D1BFo3wO.d.ts → data-layer-43pme0Gy.d.ts} +13 -1
- package/dist/{data-layer-_TZtuy-F.js → data-layer-C5oJZ8V1.js} +16 -3
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +4 -4
- package/dist/{index-DlgVg3D8.d.ts → index-D0FEnMhn.d.ts} +48 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +117 -16
- package/dist/{make-DKBn79cH.js → make-ByA-84h8.js} +1 -1
- package/dist/{makers-DeSNM0Nd.js → makers-P9kg68C8.js} +2 -2
- package/dist/{migrator-BU3_PHk8.js → migrator-B25prc9n.js} +152 -108
- package/dist/{base64url-C5GTYIL5.js → model-Dc3HunKb.js} +424 -418
- package/dist/{policies-CKIdBFKt.js → policies-KsqmlX67.js} +18 -8
- package/dist/{resource-CsYksjKc.js → resource-BQK7bkPQ.js} +5 -3
- package/dist/{tables-IrLjsomf.js → tables-DgSwge7s.js} +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +4 -4
- package/dist/{types-S6zF5IXV.d.ts → types-BgOkJy5d.d.ts} +17 -3
- package/package.json +1 -1
- package/dist/case-BnpcvhdW.js +0 -11
package/dist/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { G as ValidationError, H as validate, V as toKind, h as line, o as fileTypeNames, p as email, z as object } from "./validation-Cppmd0RV.js";
|
|
2
|
-
import { B as NeedsDatabase, C as database, D as
|
|
3
|
-
import { C as tooLarge, E as fail, S as tenantSuspended, T as Rejected, _ as json, b as routeNotFound, c as fromBody, d as route, f as HttpError, g as fileTypeRefused, h as fileTooLarge, l as readInput, m as errorResponse, o as parseListQuery, p as crossSiteRequest, r as resource, s as BODY_LIMIT, t as Resource, u as Route, v as lengthRequired, w as tooManyRequests, x as signInRequired, y as methodNotAllowed } from "./resource-
|
|
4
|
-
import {
|
|
5
|
-
import { t as kebabCase } from "./case-
|
|
6
|
-
import { t as PLATFORM_PREFIX } from "./tables-IrLjsomf.js";
|
|
7
|
-
import { t as Seed } from "./seed-BObmdKdP.js";
|
|
2
|
+
import { A as planUpdate, B as NeedsDatabase, C as database, D as recordAction, J as NotFound, L as PLATFORM, N as conditionOf, O as withTableTotals, P as modelOfRow, R as TENANT, S as clockOf, T as driverOf, U as systemClock, V as evaluate, X as UniqueFailed, j as keyCondition, k as planInsert, r as sync, t as migrate, w as decodeStored, z as checkTenancy } from "./migrator-B25prc9n.js";
|
|
3
|
+
import { C as tooLarge, E as fail, S as tenantSuspended, T as Rejected, _ as json, b as routeNotFound, c as fromBody, d as route, f as HttpError, g as fileTypeRefused, h as fileTooLarge, l as readInput, m as errorResponse, o as parseListQuery, p as crossSiteRequest, r as resource, s as BODY_LIMIT, t as Resource, u as Route, v as lengthRequired, w as tooManyRequests, x as signInRequired, y as methodNotAllowed } from "./resource-BQK7bkPQ.js";
|
|
4
|
+
import { C as asCondition$1, I as fromBase64url, L as toBase64url, U as asActor, _ as Expr, c as rowOf, h as Condition, o as fieldOf, p as sensitivity, q as Scope, r as columnName, t as Model } from "./model-Dc3HunKb.js";
|
|
5
|
+
import { r as ulid, t as kebabCase } from "./case-DpXlJCL9.js";
|
|
8
6
|
import { n as notImplemented, t as NotImplementedError } from "./errors-mCNLlXg9.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
7
|
+
import { t as PLATFORM_PREFIX } from "./tables-DgSwge7s.js";
|
|
8
|
+
import { t as Seed } from "./seed-BObmdKdP.js";
|
|
9
|
+
import { d as afterUpdate, f as bindFor, h as ownerFieldOf, m as isFalse, p as check, u as FALSE } from "./policies-KsqmlX67.js";
|
|
10
|
+
import { i as checkRoles, n as authPerms, r as checkAuth } from "./create-auth-BHdX68Ng.js";
|
|
11
|
+
import { _ as permissionsOf, a as hashOf, b as syncRoles, c as GoogleError, f as verifyIdToken, g as safeReturn, h as redirect, i as clearedCookie, l as consentUrl, m as cookieOf, n as authModels, o as sessions, p as cookie, t as makers, u as exchangeCode, v as roleKeys, y as seedAs } from "./makers-P9kg68C8.js";
|
|
12
12
|
import { t as WrongSize } from "./storage-body-7dWyMszh.js";
|
|
13
13
|
import { RegExpRouter } from "hono/router/reg-exp-router";
|
|
14
14
|
import { SmartRouter } from "hono/router/smart-router";
|
|
@@ -59,6 +59,99 @@ var Router = class {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
//#endregion
|
|
62
|
+
//#region src/api/idempotency.ts
|
|
63
|
+
/**
|
|
64
|
+
* Retries made safe: a write with an `Idempotency-Key` header runs once, and the same key again
|
|
65
|
+
* gets the first answer back (`Idempotent-Replayed: true`) without the handler running. The
|
|
66
|
+
* generated client sends a key with every write and retries a write the network lost with the
|
|
67
|
+
* same one (`client/client.ts`), so a phone whose connection drops never makes a write twice.
|
|
68
|
+
*
|
|
69
|
+
* the same key again… answer
|
|
70
|
+
* same route, user and body the first answer, replayed
|
|
71
|
+
* while the first is still running 409 InProgress (Retry-After: 1)
|
|
72
|
+
* another route, user or body 422 IdempotencyKeyReused
|
|
73
|
+
* after 24 h a new request
|
|
74
|
+
*
|
|
75
|
+
* Every write honours a key: there's no switch per endpoint. Keys live in the `_isai_idempotency`
|
|
76
|
+
* table of the database the request runs against (a tenant's own, with isolated tenancy); expired
|
|
77
|
+
* ones are deleted as new ones are written. What's kept is the pipeline's own answers (JSON, or
|
|
78
|
+
* 204), errors from 400 to 499 too; a 5xx, an answer that sets a cookie, or a handler's own
|
|
79
|
+
* stream isn't kept, so its retry runs again. A run whose answer was never kept (the isolate died
|
|
80
|
+
* halfway) is taken over by a retry after a minute.
|
|
81
|
+
*/
|
|
82
|
+
/** The header a client sends, and the one a replayed answer carries. */
|
|
83
|
+
const IDEMPOTENCY_KEY = "Idempotency-Key";
|
|
84
|
+
const REPLAYED = "Idempotent-Replayed";
|
|
85
|
+
/** How long a key is kept. */
|
|
86
|
+
const KEPT_FOR = 864e5;
|
|
87
|
+
/** Keys are a client's random ids (ULIDs, UUIDs): printable, and short. */
|
|
88
|
+
const KEY = /^[\x21-\x7e]{1,255}$/;
|
|
89
|
+
const TABLE = `"_isai_idempotency"`;
|
|
90
|
+
const inProgress = () => new HttpError(409, "InProgress", "This request is still running; try again in a moment", { "Retry-After": "1" });
|
|
91
|
+
const keyReused = () => new HttpError(422, "IdempotencyKeyReused", "This Idempotency-Key was used for another request");
|
|
92
|
+
const badKey = () => new HttpError(400, "BadIdempotencyKey", "An Idempotency-Key is 1 to 255 printable characters");
|
|
93
|
+
/**
|
|
94
|
+
* `run` once for the request's key, or the answer it gave before. `scope` is what the key must
|
|
95
|
+
* match besides the body: the route and the user. Without a key, `run` simply runs.
|
|
96
|
+
*/
|
|
97
|
+
async function idempotent(db, request, scope, run) {
|
|
98
|
+
const key = request.headers.get(IDEMPOTENCY_KEY);
|
|
99
|
+
if (key === null) return run();
|
|
100
|
+
if (!KEY.test(key)) throw badKey();
|
|
101
|
+
const driver = driverOf(db);
|
|
102
|
+
const now = clockOf(db).now().getTime();
|
|
103
|
+
const body = await digest(request);
|
|
104
|
+
const query = (text, ...params) => driver.query({
|
|
105
|
+
text,
|
|
106
|
+
params
|
|
107
|
+
});
|
|
108
|
+
await query(`DELETE FROM ${TABLE} WHERE "created_at" < ?`, now - KEPT_FOR);
|
|
109
|
+
if ((await query(`INSERT INTO ${TABLE} ("key", "scope", "body", "created_at") VALUES (?, ?, ?, ?) ON CONFLICT ("key") DO NOTHING RETURNING "key"`, key, scope, body, now)).length === 0) {
|
|
110
|
+
const [kept] = await query(`SELECT "scope", "body", "status", "response", "content_type", "created_at" FROM ${TABLE} WHERE "key" = ?`, key);
|
|
111
|
+
if (!kept) return run();
|
|
112
|
+
if (kept.scope !== scope || kept.body !== body) throw keyReused();
|
|
113
|
+
if (kept.status !== null) return replay(kept);
|
|
114
|
+
if (kept.created_at > now - 6e4) throw inProgress();
|
|
115
|
+
if ((await query(`UPDATE ${TABLE} SET "created_at" = ? WHERE "key" = ? AND "status" IS NULL AND "created_at" = ? RETURNING "key"`, now, key, kept.created_at)).length === 0) throw inProgress();
|
|
116
|
+
}
|
|
117
|
+
let response;
|
|
118
|
+
try {
|
|
119
|
+
response = await run();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
await query(`DELETE FROM ${TABLE} WHERE "key" = ?`, key);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (!keepable(response)) {
|
|
125
|
+
await query(`DELETE FROM ${TABLE} WHERE "key" = ?`, key);
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
const text = response.status === 204 ? null : await response.text();
|
|
129
|
+
const type = response.headers.get("Content-Type");
|
|
130
|
+
await query(`UPDATE ${TABLE} SET "status" = ?, "response" = ?, "content_type" = ? WHERE "key" = ?`, response.status, text, type, key);
|
|
131
|
+
return new Response(text, response);
|
|
132
|
+
}
|
|
133
|
+
/** An answer worth replaying: a client's (not a 5xx), with nothing a replay couldn't repeat. */
|
|
134
|
+
function keepable(response) {
|
|
135
|
+
if (response.status >= 500 || response.headers.has("Set-Cookie")) return false;
|
|
136
|
+
if (response.status === 204) return true;
|
|
137
|
+
return response.headers.get("Content-Type")?.startsWith("application/json") ?? false;
|
|
138
|
+
}
|
|
139
|
+
function replay(kept) {
|
|
140
|
+
return new Response(kept.response, {
|
|
141
|
+
status: kept.status ?? 200,
|
|
142
|
+
headers: {
|
|
143
|
+
...kept.content_type ? { "Content-Type": kept.content_type } : {},
|
|
144
|
+
[REPLAYED]: "true"
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** The body's SHA-256 (hex), read from a copy so the handler still has it. */
|
|
149
|
+
async function digest(request) {
|
|
150
|
+
const bytes = request.body ? await request.clone().arrayBuffer() : /* @__PURE__ */ new ArrayBuffer(0);
|
|
151
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
152
|
+
return Array.from(hash, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
62
155
|
//#region src/api/output.ts
|
|
63
156
|
/**
|
|
64
157
|
* What a handler returns, as JSON. Rows the ORM returned are found
|
|
@@ -219,7 +312,7 @@ function createPipeline(options) {
|
|
|
219
312
|
if (!signedIn && !SAFE_METHODS.has(request.method) && options.anonymousLimit) {
|
|
220
313
|
if (!await options.anonymousLimit.allow(clientIp(request))) throw tooManyRequests();
|
|
221
314
|
}
|
|
222
|
-
return await dispatch(request, url, extra, signedIn, place);
|
|
315
|
+
return await dispatch(request, url, requestId, extra, signedIn, place);
|
|
223
316
|
} catch (error) {
|
|
224
317
|
return errorResponse(error, requestId, place);
|
|
225
318
|
}
|
|
@@ -234,7 +327,7 @@ function createPipeline(options) {
|
|
|
234
327
|
});
|
|
235
328
|
}
|
|
236
329
|
/** The framework's own endpoints, then the route the path matches. */
|
|
237
|
-
async function dispatch(request, url, extra, signedIn, place) {
|
|
330
|
+
async function dispatch(request, url, requestId, extra, signedIn, place) {
|
|
238
331
|
const own = await options.framework?.(request, url);
|
|
239
332
|
if (own) return own;
|
|
240
333
|
if (!url.pathname.startsWith(`/api/`) && url.pathname !== "/api") throw routeNotFound();
|
|
@@ -249,6 +342,12 @@ function createPipeline(options) {
|
|
|
249
342
|
if (!SAFE_METHODS.has(request.method) && isCrossSite(request, url, allowedOrigin)) throw crossSiteRequest();
|
|
250
343
|
const user = signedIn?.user;
|
|
251
344
|
if (!user && !route.anonymous) throw signInRequired();
|
|
345
|
+
const run = () => answer(request, url, route, match.params, extra, user);
|
|
346
|
+
if (SAFE_METHODS.has(request.method) || !options.idempotency) return run();
|
|
347
|
+
return idempotent(options.idempotency(), request, `${place.route} ${user?.id ?? ""}`, () => run().catch((error) => errorResponse(error, requestId, place)));
|
|
348
|
+
}
|
|
349
|
+
/** A matched route's input, its handler, and its output. */
|
|
350
|
+
async function answer(request, url, route, params, extra, user) {
|
|
252
351
|
const tenant = extra.tenant;
|
|
253
352
|
const { db, can } = (options.context ?? noData)(user, extra.tenant);
|
|
254
353
|
const files = route.input === "own" ? void 0 : options.files?.(route);
|
|
@@ -265,7 +364,7 @@ function createPipeline(options) {
|
|
|
265
364
|
db,
|
|
266
365
|
can,
|
|
267
366
|
user,
|
|
268
|
-
params
|
|
367
|
+
params,
|
|
269
368
|
input,
|
|
270
369
|
request,
|
|
271
370
|
tenant
|
|
@@ -353,7 +452,7 @@ function preflight(crossOrigin) {
|
|
|
353
452
|
status: 204,
|
|
354
453
|
headers: {
|
|
355
454
|
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE",
|
|
356
|
-
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
455
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Idempotency-Key",
|
|
357
456
|
"Access-Control-Max-Age": "86400"
|
|
358
457
|
}
|
|
359
458
|
});
|
|
@@ -602,7 +701,7 @@ function boundary(options) {
|
|
|
602
701
|
}
|
|
603
702
|
case "createOne":
|
|
604
703
|
case "createMany": {
|
|
605
|
-
const node = required(await verdict(model, action));
|
|
704
|
+
const node = await withTableTotals(db, model.spec, required(await verdict(model, action)));
|
|
606
705
|
const inputs = (plan.op === "createOne" ? [plan.args[0]] : plan.args[0]).map((input) => withOwner(model, input));
|
|
607
706
|
const now = clockOf(db).now();
|
|
608
707
|
for (const input of inputs) {
|
|
@@ -944,7 +1043,7 @@ function rescope(node, scope) {
|
|
|
944
1043
|
};
|
|
945
1044
|
case "aggregate": return {
|
|
946
1045
|
...o,
|
|
947
|
-
relation: o.relation.owner
|
|
1046
|
+
relation: o.relation.owner?.scope === 0 ? {
|
|
948
1047
|
...o.relation,
|
|
949
1048
|
owner: {
|
|
950
1049
|
...o.relation.owner,
|
|
@@ -971,7 +1070,7 @@ function rescope(node, scope) {
|
|
|
971
1070
|
};
|
|
972
1071
|
case "some": return {
|
|
973
1072
|
...node,
|
|
974
|
-
relation: node.relation.owner
|
|
1073
|
+
relation: node.relation.owner?.scope === 0 ? {
|
|
975
1074
|
...node.relation,
|
|
976
1075
|
owner: {
|
|
977
1076
|
...node.relation.owner,
|
|
@@ -3125,6 +3224,7 @@ function createCore(config, env, options = {}) {
|
|
|
3125
3224
|
door,
|
|
3126
3225
|
...config.api?.rateLimit?.anonymous ? { anonymousLimit: config.api.rateLimit.anonymous } : {},
|
|
3127
3226
|
context: contextOf(currentDb, models, auth?.policies),
|
|
3227
|
+
idempotency: currentDb,
|
|
3128
3228
|
builtins: [...uploads.routes, ...models && signIns ? [
|
|
3129
3229
|
...authRoutes(models, signIns, currentDb, () => auth?.permissionKeys() ?? []),
|
|
3130
3230
|
...peopleRoutes(models, signIns, currentDb),
|
|
@@ -3196,6 +3296,7 @@ function createCore(config, env, options = {}) {
|
|
|
3196
3296
|
...config.api?.rateLimit?.anonymous ? { anonymousLimit: config.api.rateLimit.anonymous } : {},
|
|
3197
3297
|
builtins: [...authRoutes(models, signIns, platformDb, () => auth.permissionKeys()), ...peopleRoutes(models, signIns, platformDb)],
|
|
3198
3298
|
context: contextOf(platformDb, models, auth.policies),
|
|
3299
|
+
idempotency: platformDb,
|
|
3199
3300
|
session: sessionOf(signIns),
|
|
3200
3301
|
framework: firstOf([
|
|
3201
3302
|
googlePlatform({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { U as systemClock, f as dayOf, h as snapshotOf, p as generateMigration, y as MigrationError } from "./migrator-B25prc9n.js";
|
|
2
2
|
//#region src/db/migrations/cleanup.ts
|
|
3
3
|
/** Dropped tables, those that refer to others first, so no row is left pointing at a dropped one. */
|
|
4
4
|
function dropOrder(tables) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { K as Fake, q as fakeValue } from "./validation-Cppmd0RV.js";
|
|
2
|
-
import { D as
|
|
3
|
-
import {
|
|
2
|
+
import { D as recordAction, E as forUpkeep, K as CheckFailed, S as clockOf, k as planInsert } from "./migrator-B25prc9n.js";
|
|
3
|
+
import { I as fromBase64url, L as toBase64url, l as IMPLICIT_FIELDS, t as Model } from "./model-Dc3HunKb.js";
|
|
4
4
|
//#region src/auth/roles.ts
|
|
5
5
|
const same = (a, b) => a.length === b.length && a.every((value, i) => value === b[i]);
|
|
6
6
|
/** What a role in `app.ts` is stored as. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { G as ValidationError, M as id } from "./validation-Cppmd0RV.js";
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as mapOperands, B as aroundDelete, F as unbound, H as aroundUpsert, I as fromBase64url$1, K as wrap, L as toBase64url, M as readsNow, N as readsOtherRows, O as storageOf, P as tableTotals, R as actionEntry, V as aroundUpdate, W as assertNotLog, _ as Expr, c as rowOf$1, h as Condition, i as columnNode, k as fieldsOf, l as IMPLICIT_FIELDS, n as assertNotEncrypted, o as fieldOf, r as columnName, v as Now, w as conditionNode, z as aroundCreate } from "./model-Dc3HunKb.js";
|
|
3
|
+
import { r as ulid } from "./case-DpXlJCL9.js";
|
|
3
4
|
import { t as NotImplementedError } from "./errors-mCNLlXg9.js";
|
|
4
5
|
//#region src/db/errors.ts
|
|
5
6
|
var DatabaseError = class extends Error {
|
|
@@ -60,16 +61,79 @@ var NotFound = class extends DatabaseError {
|
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
//#endregion
|
|
63
|
-
//#region src/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
//#region src/internal/secret-box.ts
|
|
65
|
+
/**
|
|
66
|
+
* Encrypted values for `secret` fields: AES-256-GCM through Web Crypto, on every runtime.
|
|
67
|
+
*
|
|
68
|
+
* A sealed value is `v1.<key id>.<iv>.<ciphertext>` (base64url): a fresh 96-bit IV each time,
|
|
69
|
+
* and the key's id (from its hash) so a value sealed with another key fails clearly, and old keys
|
|
70
|
+
* can be kept for reading when rotation comes.
|
|
71
|
+
*/
|
|
72
|
+
const VERSION = "v1";
|
|
73
|
+
/** A new key's text: 32 random bytes, base64url (what `secretKey` takes). */
|
|
74
|
+
function newSecretKey() {
|
|
75
|
+
return toBase64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
72
76
|
}
|
|
77
|
+
var SecretBox = class {
|
|
78
|
+
ready;
|
|
79
|
+
/** `key`: 32 bytes, base64url (`newSecretKey()`). */
|
|
80
|
+
constructor(key) {
|
|
81
|
+
const bytes = fromBase64url$1(key);
|
|
82
|
+
if (bytes?.length !== 32) throw new TypeError("isai: secretKey must be 32 bytes, base64url (make one with newSecretKey())");
|
|
83
|
+
this.ready = (async () => {
|
|
84
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
85
|
+
return {
|
|
86
|
+
key: await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]),
|
|
87
|
+
id: toBase64url(hash.slice(0, 6))
|
|
88
|
+
};
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
async seal(plaintext) {
|
|
92
|
+
const { key, id } = await this.ready;
|
|
93
|
+
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
94
|
+
const data = new TextEncoder().encode(plaintext);
|
|
95
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt({
|
|
96
|
+
name: "AES-GCM",
|
|
97
|
+
iv
|
|
98
|
+
}, key, data));
|
|
99
|
+
return [
|
|
100
|
+
VERSION,
|
|
101
|
+
id,
|
|
102
|
+
toBase64url(iv),
|
|
103
|
+
toBase64url(sealed)
|
|
104
|
+
].join(".");
|
|
105
|
+
}
|
|
106
|
+
/** The plaintext, or why it can't be read. */
|
|
107
|
+
async open(sealed) {
|
|
108
|
+
const { key, id } = await this.ready;
|
|
109
|
+
const [version, keyId, ivText, dataText, ...rest] = sealed.split(".");
|
|
110
|
+
const iv = fromBase64url$1(ivText ?? "");
|
|
111
|
+
const data = fromBase64url$1(dataText ?? "");
|
|
112
|
+
if (version !== VERSION || rest.length > 0 || iv?.length !== 12 || !data) return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "isn't an encrypted value"
|
|
115
|
+
};
|
|
116
|
+
if (keyId !== id) return {
|
|
117
|
+
ok: false,
|
|
118
|
+
reason: "was encrypted with another key"
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
const plain = await crypto.subtle.decrypt({
|
|
122
|
+
name: "AES-GCM",
|
|
123
|
+
iv
|
|
124
|
+
}, key, data);
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
value: new TextDecoder().decode(plain)
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: "failed its integrity check (changed or corrupted)"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
73
137
|
//#endregion
|
|
74
138
|
//#region src/internal/timezone.ts
|
|
75
139
|
/**
|
|
@@ -142,6 +206,21 @@ function zone(name) {
|
|
|
142
206
|
}
|
|
143
207
|
const utc = zone("UTC");
|
|
144
208
|
//#endregion
|
|
209
|
+
//#region src/db/clock/index.ts
|
|
210
|
+
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
211
|
+
function fixedClock(start) {
|
|
212
|
+
let current = new Date(start);
|
|
213
|
+
return {
|
|
214
|
+
now: () => new Date(current),
|
|
215
|
+
set: (to) => {
|
|
216
|
+
current = new Date(to);
|
|
217
|
+
},
|
|
218
|
+
advance: (ms) => {
|
|
219
|
+
current = new Date(current.getTime() + ms);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
145
224
|
//#region src/db/conditions/evaluate.ts
|
|
146
225
|
/**
|
|
147
226
|
* Interprets a condition in JS against a row that isn't stored yet (`next`, checks before
|
|
@@ -311,95 +390,6 @@ function checkTenancy(tenancy, databases) {
|
|
|
311
390
|
if (other) throw new TypeError(`isai: with tenancy, the databases are tenant and platform; databases.${other} isn't supported yet`);
|
|
312
391
|
}
|
|
313
392
|
//#endregion
|
|
314
|
-
//#region src/internal/secret-box.ts
|
|
315
|
-
/**
|
|
316
|
-
* Encrypted values for `secret` fields: AES-256-GCM through Web Crypto, on every runtime.
|
|
317
|
-
*
|
|
318
|
-
* A sealed value is `v1.<key id>.<iv>.<ciphertext>` (base64url): a fresh 96-bit IV each time,
|
|
319
|
-
* and the key's id (from its hash) so a value sealed with another key fails clearly, and old keys
|
|
320
|
-
* can be kept for reading when rotation comes.
|
|
321
|
-
*/
|
|
322
|
-
const VERSION = "v1";
|
|
323
|
-
/** A new key's text: 32 random bytes, base64url (what `secretKey` takes). */
|
|
324
|
-
function newSecretKey() {
|
|
325
|
-
return toBase64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
326
|
-
}
|
|
327
|
-
var SecretBox = class {
|
|
328
|
-
ready;
|
|
329
|
-
/** `key`: 32 bytes, base64url (`newSecretKey()`). */
|
|
330
|
-
constructor(key) {
|
|
331
|
-
const bytes = fromBase64url$1(key);
|
|
332
|
-
if (bytes?.length !== 32) throw new TypeError("isai: secretKey must be 32 bytes, base64url (make one with newSecretKey())");
|
|
333
|
-
this.ready = (async () => {
|
|
334
|
-
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
335
|
-
return {
|
|
336
|
-
key: await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]),
|
|
337
|
-
id: toBase64url(hash.slice(0, 6))
|
|
338
|
-
};
|
|
339
|
-
})();
|
|
340
|
-
}
|
|
341
|
-
async seal(plaintext) {
|
|
342
|
-
const { key, id } = await this.ready;
|
|
343
|
-
const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
|
|
344
|
-
const data = new TextEncoder().encode(plaintext);
|
|
345
|
-
const sealed = new Uint8Array(await crypto.subtle.encrypt({
|
|
346
|
-
name: "AES-GCM",
|
|
347
|
-
iv
|
|
348
|
-
}, key, data));
|
|
349
|
-
return [
|
|
350
|
-
VERSION,
|
|
351
|
-
id,
|
|
352
|
-
toBase64url(iv),
|
|
353
|
-
toBase64url(sealed)
|
|
354
|
-
].join(".");
|
|
355
|
-
}
|
|
356
|
-
/** The plaintext, or why it can't be read. */
|
|
357
|
-
async open(sealed) {
|
|
358
|
-
const { key, id } = await this.ready;
|
|
359
|
-
const [version, keyId, ivText, dataText, ...rest] = sealed.split(".");
|
|
360
|
-
const iv = fromBase64url$1(ivText ?? "");
|
|
361
|
-
const data = fromBase64url$1(dataText ?? "");
|
|
362
|
-
if (version !== VERSION || rest.length > 0 || iv?.length !== 12 || !data) return {
|
|
363
|
-
ok: false,
|
|
364
|
-
reason: "isn't an encrypted value"
|
|
365
|
-
};
|
|
366
|
-
if (keyId !== id) return {
|
|
367
|
-
ok: false,
|
|
368
|
-
reason: "was encrypted with another key"
|
|
369
|
-
};
|
|
370
|
-
try {
|
|
371
|
-
const plain = await crypto.subtle.decrypt({
|
|
372
|
-
name: "AES-GCM",
|
|
373
|
-
iv
|
|
374
|
-
}, key, data);
|
|
375
|
-
return {
|
|
376
|
-
ok: true,
|
|
377
|
-
value: new TextDecoder().decode(plain)
|
|
378
|
-
};
|
|
379
|
-
} catch {
|
|
380
|
-
return {
|
|
381
|
-
ok: false,
|
|
382
|
-
reason: "failed its integrity check (changed or corrupted)"
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
//#endregion
|
|
388
|
-
//#region src/db/clock/index.ts
|
|
389
|
-
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
390
|
-
function fixedClock(start) {
|
|
391
|
-
let current = new Date(start);
|
|
392
|
-
return {
|
|
393
|
-
now: () => new Date(current),
|
|
394
|
-
set: (to) => {
|
|
395
|
-
current = new Date(to);
|
|
396
|
-
},
|
|
397
|
-
advance: (ms) => {
|
|
398
|
-
current = new Date(current.getTime() + ms);
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
//#endregion
|
|
403
393
|
//#region src/db/dialects/sqlite/values.ts
|
|
404
394
|
function encode(storage, value) {
|
|
405
395
|
if (value === null || value === void 0) return null;
|
|
@@ -446,7 +436,7 @@ function rowOf(state, scope) {
|
|
|
446
436
|
if (row === void 0) throw new TypeError("isai: a column is used outside the callback it belongs to");
|
|
447
437
|
return row;
|
|
448
438
|
}
|
|
449
|
-
function value(encoded, state) {
|
|
439
|
+
function value$1(encoded, state) {
|
|
450
440
|
if (state.scope.values.mode === "inline") return literal(encoded);
|
|
451
441
|
state.scope.values.params.push(encoded);
|
|
452
442
|
return "?";
|
|
@@ -473,6 +463,11 @@ function column$1(node, state) {
|
|
|
473
463
|
/** The relation's rows as `FROM … WHERE <they reference the owner>`, with their scope bound to a new alias. */
|
|
474
464
|
function relationSource(relation, state) {
|
|
475
465
|
const owner = relation.owner;
|
|
466
|
+
if (!owner) {
|
|
467
|
+
const name = alias(state);
|
|
468
|
+
state.rows.set(relation.scope, name);
|
|
469
|
+
return `FROM ${quote(relation.table)} AS ${name} WHERE 1`;
|
|
470
|
+
}
|
|
476
471
|
const last = owner.path.at(-1);
|
|
477
472
|
const ownerRow = rowOf(state, owner.scope);
|
|
478
473
|
const ownerId = last ? columnThrough(state, ownerRow, owner.path.slice(0, -1), last.column) : `${ownerRow}."id"`;
|
|
@@ -483,11 +478,11 @@ function relationSource(relation, state) {
|
|
|
483
478
|
function operand(node, partner, state) {
|
|
484
479
|
switch (node.t) {
|
|
485
480
|
case "column": return column$1(node, state);
|
|
486
|
-
case "value": return value(partner ? encode(partner, node.value) : node.value, state);
|
|
481
|
+
case "value": return value$1(partner ? encode(partner, node.value) : node.value, state);
|
|
487
482
|
case "now": {
|
|
488
483
|
if (!state.scope.now) throw new TypeError("isai: `now` can't be used here");
|
|
489
484
|
const at = state.scope.now.getTime() + node.offsetMs;
|
|
490
|
-
return value(partner === "date" ? state.scope.zone.localDate(at) : at, state);
|
|
485
|
+
return value$1(partner === "date" ? state.scope.zone.localDate(at) : at, state);
|
|
491
486
|
}
|
|
492
487
|
case "truth": return `(${compileIn(node.condition, state)})`;
|
|
493
488
|
case "local": {
|
|
@@ -581,13 +576,13 @@ function compileIn(node, state) {
|
|
|
581
576
|
if (node.values.length === 0) return "0";
|
|
582
577
|
const left = operand(node.operand, void 0, state);
|
|
583
578
|
const storage = storageOf(node.operand);
|
|
584
|
-
return `${left} IN (${(isSequence(node.operand) ? node.values.map(sequenceNumber) : node.values).map((v) => value(storage ? encode(storage, v) : v, state)).join(", ")})`;
|
|
579
|
+
return `${left} IN (${(isSequence(node.operand) ? node.values.map(sequenceNumber) : node.values).map((v) => value$1(storage ? encode(storage, v) : v, state)).join(", ")})`;
|
|
585
580
|
}
|
|
586
581
|
case "text": {
|
|
587
582
|
const target = operand(node.operand, void 0, state);
|
|
588
|
-
if (node.op === "icontains") return `instr(lower(${target}), lower(${value(node.value, state)})) > 0`;
|
|
589
|
-
if (node.op === "istartswith") return `instr(lower(${target}), lower(${value(node.value, state)})) = 1`;
|
|
590
|
-
return `instr(${target}, ${value(node.value, state)}) ${node.op === "startsWith" ? "= 1" : "> 0"}`;
|
|
583
|
+
if (node.op === "icontains") return `instr(lower(${target}), lower(${value$1(node.value, state)})) > 0`;
|
|
584
|
+
if (node.op === "istartswith") return `instr(lower(${target}), lower(${value$1(node.value, state)})) = 1`;
|
|
585
|
+
return `instr(${target}, ${value$1(node.value, state)}) ${node.op === "startsWith" ? "= 1" : "> 0"}`;
|
|
591
586
|
}
|
|
592
587
|
case "some": return `EXISTS (SELECT 1 ${relationSource(node.relation, state)} AND ${compileIn(node.where, state)})`;
|
|
593
588
|
case "and": return node.items.length === 0 ? "1" : balanced(node.items, "AND", state);
|
|
@@ -851,6 +846,12 @@ function count(model, where, context) {
|
|
|
851
846
|
return writer.sql(`SELECT COUNT(*) AS ${quote("count")} FROM ${quote(model.table)}${writer.where(where)}`);
|
|
852
847
|
});
|
|
853
848
|
}
|
|
849
|
+
function value(model, node, context) {
|
|
850
|
+
return withinLimit(context, (mode) => {
|
|
851
|
+
const writer = new Writer(model, mode, context);
|
|
852
|
+
return writer.sql(`SELECT ${compileOperand(node, writer.scope)} AS ${quote("value")}`);
|
|
853
|
+
});
|
|
854
|
+
}
|
|
854
855
|
function exists(model, where, context) {
|
|
855
856
|
return withinLimit(context, (mode) => {
|
|
856
857
|
const writer = new Writer(model, mode, context);
|
|
@@ -1287,6 +1288,7 @@ const sqlite = {
|
|
|
1287
1288
|
totals,
|
|
1288
1289
|
decodeTotals,
|
|
1289
1290
|
exists,
|
|
1291
|
+
value,
|
|
1290
1292
|
update,
|
|
1291
1293
|
delete: remove,
|
|
1292
1294
|
reorder,
|
|
@@ -1306,7 +1308,8 @@ const sqlite = {
|
|
|
1306
1308
|
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS} ("name" TEXT PRIMARY KEY, "kind" TEXT NOT NULL, "checksum" TEXT NOT NULL, "release" TEXT NOT NULL, "applied_at" INTEGER NOT NULL, "cursor" TEXT, "rerun" INTEGER NOT NULL DEFAULT 0)`,
|
|
1307
1309
|
`CREATE TABLE IF NOT EXISTS "_isai_releases" ("id" TEXT PRIMARY KEY, "seq" INTEGER NOT NULL UNIQUE, "commit" TEXT, "worker_version" TEXT, "status" TEXT NOT NULL, "schema" TEXT NOT NULL, "by" TEXT, "reason" TEXT, "started_at" INTEGER NOT NULL, "live_at" INTEGER, "rolled_back_at" INTEGER)`,
|
|
1308
1310
|
`CREATE TABLE IF NOT EXISTS "_isai_lock" ("id" INTEGER PRIMARY KEY CHECK ("id" = 1), "holder" TEXT, "command" TEXT, "since" INTEGER, "expires" INTEGER, "hold_release" TEXT, "hold_reason" TEXT)`,
|
|
1309
|
-
`CREATE TABLE IF NOT EXISTS ${SEQUENCES} ("name" TEXT PRIMARY KEY, "value" INTEGER NOT NULL)
|
|
1311
|
+
`CREATE TABLE IF NOT EXISTS ${SEQUENCES} ("name" TEXT PRIMARY KEY, "value" INTEGER NOT NULL)`,
|
|
1312
|
+
`CREATE TABLE IF NOT EXISTS "_isai_idempotency" ("key" TEXT PRIMARY KEY, "scope" TEXT NOT NULL, "body" TEXT NOT NULL, "status" INTEGER, "response" TEXT, "content_type" TEXT, "created_at" INTEGER NOT NULL)`
|
|
1310
1313
|
],
|
|
1311
1314
|
rules: rulesOf,
|
|
1312
1315
|
refill,
|
|
@@ -1327,6 +1330,17 @@ function dialectFor(name) {
|
|
|
1327
1330
|
throw new NotImplementedError(`the ${name} dialect`);
|
|
1328
1331
|
}
|
|
1329
1332
|
//#endregion
|
|
1333
|
+
//#region src/db/orm/tag.ts
|
|
1334
|
+
const MODEL = Symbol.for("isai.model");
|
|
1335
|
+
function tagRow(row, model) {
|
|
1336
|
+
row[MODEL] = model;
|
|
1337
|
+
return row;
|
|
1338
|
+
}
|
|
1339
|
+
/** The model a value was read from, when it's a row the ORM returned (or a copy of one). */
|
|
1340
|
+
function modelOfRow(value) {
|
|
1341
|
+
return value[MODEL];
|
|
1342
|
+
}
|
|
1343
|
+
//#endregion
|
|
1330
1344
|
//#region src/db/orm/translate.ts
|
|
1331
1345
|
/** The field stored in `column`. */
|
|
1332
1346
|
function fieldAt(model, column) {
|
|
@@ -2395,6 +2409,8 @@ var Transaction = class {
|
|
|
2395
2409
|
};
|
|
2396
2410
|
const UPKEEP = Symbol("isai:upkeep");
|
|
2397
2411
|
const CLOCK = Symbol("isai:clock");
|
|
2412
|
+
const DRIVER = Symbol("isai:driver");
|
|
2413
|
+
const TOTALS = Symbol("isai:totals");
|
|
2398
2414
|
const RECORD = Symbol("isai:record");
|
|
2399
2415
|
const DECODE = Symbol("isai:decode");
|
|
2400
2416
|
var Database = class Database {
|
|
@@ -2419,6 +2435,23 @@ var Database = class Database {
|
|
|
2419
2435
|
[CLOCK]() {
|
|
2420
2436
|
return this.engine.clock;
|
|
2421
2437
|
}
|
|
2438
|
+
/** See `withTableTotals`. */
|
|
2439
|
+
async [TOTALS](model, node) {
|
|
2440
|
+
const values = /* @__PURE__ */ new Map();
|
|
2441
|
+
for (const total of tableTotals(node)) {
|
|
2442
|
+
const [row] = await this.engine.run(this.engine.dialect.value(model, total, this.engine.context()), [model]);
|
|
2443
|
+
values.set(total, Number(row?.value ?? 0));
|
|
2444
|
+
}
|
|
2445
|
+
if (values.size === 0) return node;
|
|
2446
|
+
return mapOperands(node, (o) => values.has(o) ? {
|
|
2447
|
+
t: "value",
|
|
2448
|
+
value: values.get(o)
|
|
2449
|
+
} : o);
|
|
2450
|
+
}
|
|
2451
|
+
/** See `driverOf`. */
|
|
2452
|
+
[DRIVER]() {
|
|
2453
|
+
return this.engine.driver;
|
|
2454
|
+
}
|
|
2422
2455
|
/**
|
|
2423
2456
|
* The post-commit write stream: `listener` is called after every committed write with what it
|
|
2424
2457
|
* created, changed and deleted (an `atomic`'s writes in one call). Returns the unsubscribe.
|
|
@@ -2591,6 +2624,17 @@ function decodeStored(db, model, row) {
|
|
|
2591
2624
|
function clockOf(db) {
|
|
2592
2625
|
return db[CLOCK]();
|
|
2593
2626
|
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The rule's totals over whole tables (`rows(Product).count()`), each run as a query and replaced
|
|
2629
|
+
* by its value, so the rule can be checked against a row in JS (a create's new row). Internal.
|
|
2630
|
+
*/
|
|
2631
|
+
async function withTableTotals(db, model, node) {
|
|
2632
|
+
return db[TOTALS](model, node);
|
|
2633
|
+
}
|
|
2634
|
+
/** The driver under a database, for the framework's own `_isai_*` tables (idempotency keys). */
|
|
2635
|
+
function driverOf(db) {
|
|
2636
|
+
return db[DRIVER]();
|
|
2637
|
+
}
|
|
2594
2638
|
function database(options) {
|
|
2595
2639
|
return new Database(options);
|
|
2596
2640
|
}
|
|
@@ -3561,4 +3605,4 @@ async function migrateFresh(driver, models, options = {}) {
|
|
|
3561
3605
|
await sync(driver, manifest, options);
|
|
3562
3606
|
}
|
|
3563
3607
|
//#endregion
|
|
3564
|
-
export {
|
|
3608
|
+
export { planUpdate as A, NeedsDatabase as B, database as C, recordAction as D, forUpkeep as E, dialectFor as F, newSecretKey as G, fixedClock as H, sqlite as I, NotFound as J, CheckFailed as K, PLATFORM as L, Query as M, conditionOf as N, withTableTotals as O, modelOfRow as P, TENANT as R, clockOf as S, driverOf as T, systemClock as U, evaluate as V, utc as W, UniqueFailed as X, ReferenceFailed as Y, TODO as _, currentRelease as a, Database as b, rollback as c, applyRules as d, dayOf as f, UnsafeMigration as g, snapshotOf as h, acquireLock as i, keyCondition as j, planInsert as k, setReleaseStatus as l, emptySnapshot as m, migrateFresh as n, holdOf as o, generateMigration as p, DatabaseError as q, sync as r, releaseLock as s, migrate as t, startRelease as u, dataStep as v, decodeStored as w, Transaction as x, MigrationError as y, checkTenancy as z };
|