@pramen/server 0.0.35 → 0.0.36
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/index.d.ts +1 -1
- package/dist/runtime/acl.js +16 -2
- package/dist/runtime/db.js +49 -2
- package/dist/runtime/ddl.d.ts +30 -4
- package/dist/runtime/ddl.js +52 -4
- package/dist/runtime/driver.d.ts +16 -0
- package/dist/runtime/driver.js +10 -0
- package/dist/runtime/migrate.js +212 -17
- package/dist/runtime/read-engine.d.ts +13 -0
- package/dist/runtime/read-engine.js +35 -0
- package/dist/sdk/infer.d.ts +10 -4
- package/dist/sdk/schema.d.ts +69 -3
- package/dist/sdk/schema.js +30 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/runtime/acl.ts +14 -2
- package/src/runtime/db.ts +39 -2
- package/src/runtime/ddl.ts +67 -5
- package/src/runtime/driver.ts +17 -0
- package/src/runtime/migrate.ts +219 -17
- package/src/runtime/read-engine.ts +35 -0
- package/src/sdk/infer.ts +10 -4
- package/src/sdk/schema.ts +77 -4
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Dialect } from "./driver";
|
|
2
2
|
export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
|
|
3
|
+
/** Substring match mode for the structured string operators (auto-escaping, so the
|
|
4
|
+
* needle's `%`/`_` are literal). Case-insensitive, matching SQLite's default LIKE. */
|
|
5
|
+
export type StrMode = "contains" | "prefix" | "suffix";
|
|
3
6
|
export type SqlExpr = {
|
|
4
7
|
t: "true";
|
|
5
8
|
} | {
|
|
@@ -18,12 +21,20 @@ export type SqlExpr = {
|
|
|
18
21
|
t: "null";
|
|
19
22
|
col: string;
|
|
20
23
|
negate: boolean;
|
|
24
|
+
} | {
|
|
25
|
+
t: "strmatch";
|
|
26
|
+
col: string;
|
|
27
|
+
needle: string;
|
|
28
|
+
mode: StrMode;
|
|
21
29
|
} | {
|
|
22
30
|
t: "and";
|
|
23
31
|
parts: SqlExpr[];
|
|
24
32
|
} | {
|
|
25
33
|
t: "or";
|
|
26
34
|
parts: SqlExpr[];
|
|
35
|
+
} | {
|
|
36
|
+
t: "not";
|
|
37
|
+
expr: SqlExpr;
|
|
27
38
|
} | {
|
|
28
39
|
t: "sub";
|
|
29
40
|
outerCol: string;
|
|
@@ -37,8 +48,10 @@ export declare const FALSE: SqlExpr;
|
|
|
37
48
|
export declare const cmp: (op: CmpOp, col: string, value: unknown) => SqlExpr;
|
|
38
49
|
export declare const isNull: (col: string, negate?: boolean) => SqlExpr;
|
|
39
50
|
export declare const inList: (col: string, values: unknown[], negate?: boolean) => SqlExpr;
|
|
51
|
+
export declare const strMatch: (col: string, needle: string, mode: StrMode) => SqlExpr;
|
|
40
52
|
export declare const and: (...parts: SqlExpr[]) => SqlExpr;
|
|
41
53
|
export declare const or: (...parts: SqlExpr[]) => SqlExpr;
|
|
54
|
+
export declare const not: (expr: SqlExpr) => SqlExpr;
|
|
42
55
|
/** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
|
|
43
56
|
export declare const eq: (col: string, value: unknown) => SqlExpr;
|
|
44
57
|
/** Compile a structured user predicate into a SqlExpr.
|
|
@@ -15,8 +15,10 @@ export const FALSE = { t: "false" };
|
|
|
15
15
|
export const cmp = (op, col, value) => ({ t: "cmp", op, col, value });
|
|
16
16
|
export const isNull = (col, negate = false) => ({ t: "null", col, negate });
|
|
17
17
|
export const inList = (col, values, negate = false) => ({ t: "in", col, values, negate });
|
|
18
|
+
export const strMatch = (col, needle, mode) => ({ t: "strmatch", col, needle, mode });
|
|
18
19
|
export const and = (...parts) => ({ t: "and", parts });
|
|
19
20
|
export const or = (...parts) => ({ t: "or", parts });
|
|
21
|
+
export const not = (expr) => ({ t: "not", expr });
|
|
20
22
|
/** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
|
|
21
23
|
export const eq = (col, value) => (value === null ? isNull(col) : cmp("=", col, value));
|
|
22
24
|
/** Compile a structured user predicate into a SqlExpr.
|
|
@@ -30,6 +32,9 @@ export function compileWhere(input) {
|
|
|
30
32
|
else if (k === "OR") {
|
|
31
33
|
parts.push(or(...v.map(compileWhere)));
|
|
32
34
|
}
|
|
35
|
+
else if (k === "NOT") {
|
|
36
|
+
parts.push(not(compileWhere(v)));
|
|
37
|
+
}
|
|
33
38
|
else {
|
|
34
39
|
parts.push(columnPredicate(k, v));
|
|
35
40
|
}
|
|
@@ -62,6 +67,15 @@ function columnPredicate(col, v) {
|
|
|
62
67
|
case "like":
|
|
63
68
|
ops.push(cmp("LIKE", col, val));
|
|
64
69
|
break;
|
|
70
|
+
case "contains":
|
|
71
|
+
ops.push(strMatch(col, String(val), "contains"));
|
|
72
|
+
break;
|
|
73
|
+
case "startsWith":
|
|
74
|
+
ops.push(strMatch(col, String(val), "prefix"));
|
|
75
|
+
break;
|
|
76
|
+
case "endsWith":
|
|
77
|
+
ops.push(strMatch(col, String(val), "suffix"));
|
|
78
|
+
break;
|
|
65
79
|
case "in":
|
|
66
80
|
ops.push(inList(col, val));
|
|
67
81
|
break;
|
|
@@ -96,6 +110,15 @@ export function compileExpr(expr, dialect, params = []) {
|
|
|
96
110
|
return { sql: `${dialect.id(expr.col)} ${expr.op} ${dialect.placeholder(params.length)}`, params };
|
|
97
111
|
case "null":
|
|
98
112
|
return { sql: `${dialect.id(expr.col)} IS ${expr.negate ? "NOT " : ""}NULL`, params };
|
|
113
|
+
case "strmatch": {
|
|
114
|
+
// Escape the LIKE metacharacters in the needle (\, %, _) so they match literally,
|
|
115
|
+
// then wrap with wildcards per mode. ESCAPE '\' declares the escape char (SQLite +
|
|
116
|
+
// Postgres both support it). LIKE is ASCII-case-insensitive by default.
|
|
117
|
+
const esc = expr.needle.replace(/[\\%_]/g, "\\$&");
|
|
118
|
+
const pattern = expr.mode === "contains" ? `%${esc}%` : expr.mode === "prefix" ? `${esc}%` : `%${esc}`;
|
|
119
|
+
params.push(dialect.encode(pattern));
|
|
120
|
+
return { sql: `${dialect.id(expr.col)} LIKE ${dialect.placeholder(params.length)} ESCAPE '\\'`, params };
|
|
121
|
+
}
|
|
99
122
|
case "in": {
|
|
100
123
|
if (expr.values.length === 0)
|
|
101
124
|
return { sql: expr.negate ? "1" : "0", params }; // empty: notIn=>all, in=>none
|
|
@@ -110,6 +133,8 @@ export function compileExpr(expr, dialect, params = []) {
|
|
|
110
133
|
const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
|
|
111
134
|
return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
|
|
112
135
|
}
|
|
136
|
+
case "not":
|
|
137
|
+
return { sql: `NOT (${compileExpr(expr.expr, dialect, params).sql})`, params };
|
|
113
138
|
case "sub": {
|
|
114
139
|
// Inner predicate shares `params`, so placeholder numbering stays correct
|
|
115
140
|
// across dialects (? and $n alike).
|
|
@@ -169,6 +194,14 @@ export function evalExpr(expr, row) {
|
|
|
169
194
|
const isNullVal = v === null || v === undefined;
|
|
170
195
|
return expr.negate ? !isNullVal : isNullVal;
|
|
171
196
|
}
|
|
197
|
+
case "strmatch": {
|
|
198
|
+
const left = row[expr.col];
|
|
199
|
+
if (typeof left !== "string")
|
|
200
|
+
return false;
|
|
201
|
+
const s = left.toLowerCase();
|
|
202
|
+
const n = expr.needle.toLowerCase(); // CI, mirroring SQLite's default LIKE
|
|
203
|
+
return expr.mode === "contains" ? s.includes(n) : expr.mode === "prefix" ? s.startsWith(n) : s.endsWith(n);
|
|
204
|
+
}
|
|
172
205
|
case "in": {
|
|
173
206
|
const left = bind(row[expr.col]);
|
|
174
207
|
if (left === null || left === undefined)
|
|
@@ -182,6 +215,8 @@ export function evalExpr(expr, row) {
|
|
|
182
215
|
return expr.parts.every((p) => evalExpr(p, row));
|
|
183
216
|
case "or":
|
|
184
217
|
return expr.parts.some((p) => evalExpr(p, row));
|
|
218
|
+
case "not":
|
|
219
|
+
return !evalExpr(expr.expr, row);
|
|
185
220
|
case "sub":
|
|
186
221
|
// Relation traversal needs a SQL round-trip; it isn't supported in the
|
|
187
222
|
// in-memory cell-ACL `when` evaluator (those predicates must be single-table).
|
package/dist/sdk/infer.d.ts
CHANGED
|
@@ -24,7 +24,9 @@ export type InferRow<F extends EntityFields> = {
|
|
|
24
24
|
export type ProjectedRow<F extends EntityFields> = {
|
|
25
25
|
[K in keyof F]?: Cell<F[K]>;
|
|
26
26
|
};
|
|
27
|
-
/** Operators available on a column predicate. `like
|
|
27
|
+
/** Operators available on a column predicate. String ops (`like`/`contains`/
|
|
28
|
+
* `startsWith`/`endsWith`) are string-only; `contains`/`startsWith`/`endsWith` escape
|
|
29
|
+
* the needle's wildcards (unlike `like`, where the caller writes `%`/`_`). */
|
|
28
30
|
export interface WhereOps<V> {
|
|
29
31
|
eq?: V | null;
|
|
30
32
|
ne?: V | null;
|
|
@@ -35,15 +37,19 @@ export interface WhereOps<V> {
|
|
|
35
37
|
in?: V[];
|
|
36
38
|
notIn?: V[];
|
|
37
39
|
like?: V extends string ? string : never;
|
|
40
|
+
contains?: V extends string ? string : never;
|
|
41
|
+
startsWith?: V extends string ? string : never;
|
|
42
|
+
endsWith?: V extends string ? string : never;
|
|
38
43
|
isNull?: boolean;
|
|
39
44
|
}
|
|
40
45
|
/** Predicate input: per-column equality shorthand or an operator object, plus
|
|
41
|
-
* nestable AND/OR groups. */
|
|
46
|
+
* nestable AND/OR/NOT groups. */
|
|
42
47
|
export type WhereInput<F extends EntityFields> = {
|
|
43
48
|
[K in keyof F]?: FieldTsType<F[K]> | null | WhereOps<FieldTsType<F[K]>>;
|
|
44
49
|
} & {
|
|
45
50
|
AND?: WhereInput<F>[];
|
|
46
51
|
OR?: WhereInput<F>[];
|
|
52
|
+
NOT?: WhereInput<F>;
|
|
47
53
|
};
|
|
48
54
|
type PrevDepth = [never, 0, 1, 2, 3, 4, 5];
|
|
49
55
|
type RelTargetTable<S extends SchemaDef, R> = R extends {
|
|
@@ -80,10 +86,10 @@ export type FieldsOf<E> = E extends EntityDef<infer F, RelationDefs> ? F : never
|
|
|
80
86
|
/** Extract a schema entry's relations. */
|
|
81
87
|
export type RelationsOf<E> = E extends EntityDef<EntityFields, infer R> ? R : Record<string, never>;
|
|
82
88
|
type RelValue<S extends SchemaDef, Rel> = Rel extends {
|
|
83
|
-
kind: "belongsTo";
|
|
89
|
+
kind: "belongsTo" | "oneHasOne" | "oneHasOneInverse";
|
|
84
90
|
target: infer Tg;
|
|
85
91
|
} ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>> | null : never : Rel extends {
|
|
86
|
-
kind: "hasMany";
|
|
92
|
+
kind: "hasMany" | "manyToMany";
|
|
87
93
|
target: infer Tg;
|
|
88
94
|
} ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>>[] : never : never;
|
|
89
95
|
/** The relation properties added to a row by `with`. Optional (present only when selected). */
|
package/dist/sdk/schema.d.ts
CHANGED
|
@@ -74,11 +74,18 @@ declare const builders: {
|
|
|
74
74
|
};
|
|
75
75
|
export type FieldBuilders = typeof builders;
|
|
76
76
|
export type EntityFields = Record<string, FieldDef>;
|
|
77
|
+
/** FK ON DELETE behavior for an owning relation's real foreign key. `restrict` (the
|
|
78
|
+
* default) blocks deleting a referenced row; `cascade` deletes the referencing rows;
|
|
79
|
+
* `setNull` nulls the FK column (which must be nullable). Enforced by the SQLite engine
|
|
80
|
+
* at runtime on both DO and D1. */
|
|
81
|
+
export type OnDelete = "cascade" | "setNull" | "restrict";
|
|
77
82
|
export interface BelongsToDef<T extends string = string> {
|
|
78
83
|
readonly kind: "belongsTo";
|
|
79
84
|
readonly target: T;
|
|
80
|
-
/** Local column holding the target's primary key. */
|
|
85
|
+
/** Local column holding the target's primary key (a real FK: REFERENCES target(pk)). */
|
|
81
86
|
readonly column: string;
|
|
87
|
+
/** ON DELETE action for the FK; omitted ⇒ `restrict` (SQLite default). */
|
|
88
|
+
readonly onDelete?: OnDelete;
|
|
82
89
|
}
|
|
83
90
|
export interface HasManyDef<T extends string = string> {
|
|
84
91
|
readonly kind: "hasMany";
|
|
@@ -86,19 +93,73 @@ export interface HasManyDef<T extends string = string> {
|
|
|
86
93
|
/** Column on the target referring back to this entity's primary key. */
|
|
87
94
|
readonly column: string;
|
|
88
95
|
}
|
|
89
|
-
|
|
96
|
+
/** Many-to-many via an explicit junction entity. Logical (no FK constraints), like the
|
|
97
|
+
* other relation kinds: `through` is a normal entity you define and write to directly;
|
|
98
|
+
* `sourceColumn`/`targetColumn` are its columns holding this entity's and the target's
|
|
99
|
+
* primary keys. Source, junction, and target must share a partition (single-DO traversal). */
|
|
100
|
+
export interface ManyToManyDef<T extends string = string> {
|
|
101
|
+
readonly kind: "manyToMany";
|
|
102
|
+
readonly target: T;
|
|
103
|
+
readonly through: string;
|
|
104
|
+
readonly sourceColumn: string;
|
|
105
|
+
readonly targetColumn: string;
|
|
106
|
+
}
|
|
107
|
+
/** One-to-one (owning side): THIS entity holds `column` = the target's primary key, and
|
|
108
|
+
* the pairing is 1:1 — mark `column` `unique()` for the DB-enforced guarantee. Reads as a
|
|
109
|
+
* single target (like belongsTo); FK-capable via `onDelete`. */
|
|
110
|
+
export interface OneHasOneDef<T extends string = string> {
|
|
111
|
+
readonly kind: "oneHasOne";
|
|
112
|
+
readonly target: T;
|
|
113
|
+
readonly column: string;
|
|
114
|
+
readonly onDelete?: OnDelete;
|
|
115
|
+
}
|
|
116
|
+
/** One-to-one (inverse side): the TARGET holds `column` referencing THIS entity's primary
|
|
117
|
+
* key. Reads as a single target (the reverse of a oneHasOne), or null. */
|
|
118
|
+
export interface OneHasOneInverseDef<T extends string = string> {
|
|
119
|
+
readonly kind: "oneHasOneInverse";
|
|
120
|
+
readonly target: T;
|
|
121
|
+
readonly column: string;
|
|
122
|
+
}
|
|
123
|
+
export type RelationDef = BelongsToDef | HasManyDef | ManyToManyDef | OneHasOneDef | OneHasOneInverseDef;
|
|
90
124
|
export type RelationDefs = Record<string, RelationDef>;
|
|
91
125
|
declare const relationBuilders: {
|
|
92
|
-
belongsTo: <T extends string>(target: T, column: string
|
|
126
|
+
belongsTo: <T extends string>(target: T, column: string, opts?: {
|
|
127
|
+
onDelete?: OnDelete;
|
|
128
|
+
}) => {
|
|
93
129
|
readonly kind: "belongsTo";
|
|
94
130
|
readonly target: T;
|
|
95
131
|
readonly column: string;
|
|
132
|
+
readonly onDelete: OnDelete | undefined;
|
|
96
133
|
};
|
|
97
134
|
hasMany: <T extends string>(target: T, column: string) => {
|
|
98
135
|
readonly kind: "hasMany";
|
|
99
136
|
readonly target: T;
|
|
100
137
|
readonly column: string;
|
|
101
138
|
};
|
|
139
|
+
oneHasOne: <T extends string>(target: T, column: string, opts?: {
|
|
140
|
+
onDelete?: OnDelete;
|
|
141
|
+
}) => {
|
|
142
|
+
readonly kind: "oneHasOne";
|
|
143
|
+
readonly target: T;
|
|
144
|
+
readonly column: string;
|
|
145
|
+
readonly onDelete: OnDelete | undefined;
|
|
146
|
+
};
|
|
147
|
+
oneHasOneInverse: <T extends string>(target: T, column: string) => {
|
|
148
|
+
readonly kind: "oneHasOneInverse";
|
|
149
|
+
readonly target: T;
|
|
150
|
+
readonly column: string;
|
|
151
|
+
};
|
|
152
|
+
manyToMany: <T extends string>(target: T, opts: {
|
|
153
|
+
through: string;
|
|
154
|
+
sourceColumn: string;
|
|
155
|
+
targetColumn: string;
|
|
156
|
+
}) => {
|
|
157
|
+
readonly kind: "manyToMany";
|
|
158
|
+
readonly target: T;
|
|
159
|
+
readonly through: string;
|
|
160
|
+
readonly sourceColumn: string;
|
|
161
|
+
readonly targetColumn: string;
|
|
162
|
+
};
|
|
102
163
|
};
|
|
103
164
|
export type RelationBuilders = typeof relationBuilders;
|
|
104
165
|
/** The default partition name for entities that don't declare one. */
|
|
@@ -134,10 +195,15 @@ export interface EntityDef<F extends EntityFields = EntityFields, R extends Rela
|
|
|
134
195
|
readonly partition: string;
|
|
135
196
|
/** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
|
|
136
197
|
readonly triggers: readonly TriggerDef[];
|
|
198
|
+
/** Composite (multi-column) UNIQUE constraints, each a tuple of column names, enforced
|
|
199
|
+
* via a managed unique index. Single-column uniqueness stays on the field (`unique()`).
|
|
200
|
+
* Always an array (possibly empty). */
|
|
201
|
+
readonly uniques: readonly (readonly string[])[];
|
|
137
202
|
}
|
|
138
203
|
export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R, opts?: {
|
|
139
204
|
partition?: string;
|
|
140
205
|
triggers?: readonly TriggerDef[];
|
|
206
|
+
unique?: readonly (readonly string[])[];
|
|
141
207
|
}): EntityDef<F, R>;
|
|
142
208
|
/** The triggers declared on an entity (empty if none / unknown entity). */
|
|
143
209
|
export declare function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[];
|
package/dist/sdk/schema.js
CHANGED
|
@@ -25,8 +25,11 @@ const builders = {
|
|
|
25
25
|
uuid: () => ({ type: "uuid" }),
|
|
26
26
|
};
|
|
27
27
|
const relationBuilders = {
|
|
28
|
-
belongsTo: (target, column) => ({ kind: "belongsTo", target, column }),
|
|
28
|
+
belongsTo: (target, column, opts) => ({ kind: "belongsTo", target, column, onDelete: opts?.onDelete }),
|
|
29
29
|
hasMany: (target, column) => ({ kind: "hasMany", target, column }),
|
|
30
|
+
oneHasOne: (target, column, opts) => ({ kind: "oneHasOne", target, column, onDelete: opts?.onDelete }),
|
|
31
|
+
oneHasOneInverse: (target, column) => ({ kind: "oneHasOneInverse", target, column }),
|
|
32
|
+
manyToMany: (target, opts) => ({ kind: "manyToMany", target, through: opts.through, sourceColumn: opts.sourceColumn, targetColumn: opts.targetColumn }),
|
|
30
33
|
};
|
|
31
34
|
/** The default partition name for entities that don't declare one. */
|
|
32
35
|
export const DEFAULT_PARTITION = "default";
|
|
@@ -53,6 +56,7 @@ export function Entity(build, relations, opts) {
|
|
|
53
56
|
relations: (relations ? relations(relationBuilders) : {}),
|
|
54
57
|
partition: opts?.partition ?? DEFAULT_PARTITION,
|
|
55
58
|
triggers: opts?.triggers ?? [],
|
|
59
|
+
uniques: opts?.unique ?? [],
|
|
56
60
|
};
|
|
57
61
|
}
|
|
58
62
|
/** The triggers declared on an entity (empty if none / unknown entity). */
|
|
@@ -185,6 +189,21 @@ export function validateSchema(schema) {
|
|
|
185
189
|
`'${pE}' but target '${rel.target}' is in '${pT}'. Relations cannot cross partitions — ` +
|
|
186
190
|
`put both entities in the same partition or drop the relation.`);
|
|
187
191
|
}
|
|
192
|
+
if (rel.kind === "manyToMany") {
|
|
193
|
+
const through = schema[rel.through];
|
|
194
|
+
if (!through) {
|
|
195
|
+
throw new Error(`relation '${entity}.${relName}' names an unknown junction entity '${rel.through}'.`);
|
|
196
|
+
}
|
|
197
|
+
for (const [label, col] of [["sourceColumn", rel.sourceColumn], ["targetColumn", rel.targetColumn]]) {
|
|
198
|
+
if (!(col in through.fields)) {
|
|
199
|
+
throw new Error(`relation '${entity}.${relName}' ${label} '${col}' is not a column of junction '${rel.through}'.`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (partitionOf(schema, rel.through) !== pE) {
|
|
203
|
+
throw new Error(`relation '${entity}.${relName}' junction '${rel.through}' is in a different partition than '${entity}' — ` +
|
|
204
|
+
`the source, junction, and target must share a partition (traversal is single-DO).`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
188
207
|
}
|
|
189
208
|
for (const t of def.triggers) {
|
|
190
209
|
if (!t.task)
|
|
@@ -199,5 +218,15 @@ export function validateSchema(schema) {
|
|
|
199
218
|
}
|
|
200
219
|
}
|
|
201
220
|
}
|
|
221
|
+
for (const cols of def.uniques) {
|
|
222
|
+
if (cols.length < 2) {
|
|
223
|
+
throw new Error(`composite unique on '${entity}' needs at least two columns (use unique() for one).`);
|
|
224
|
+
}
|
|
225
|
+
for (const c of cols) {
|
|
226
|
+
if (!(c in def.fields)) {
|
|
227
|
+
throw new Error(`composite unique on '${entity}' references unknown column '${c}'.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
202
231
|
}
|
|
203
232
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.36",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/index.ts
CHANGED
package/src/runtime/acl.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
isInputMarker,
|
|
23
23
|
isResolver,
|
|
24
24
|
} from "../sdk/acl";
|
|
25
|
-
import { and, compileWhere, evalExpr, FALSE, or, TRUE, type SqlExpr } from "./read-engine";
|
|
25
|
+
import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
|
|
26
26
|
import { BadRequest, PramenError } from "./errors";
|
|
27
27
|
import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
|
|
28
28
|
|
|
@@ -260,6 +260,8 @@ function assertReadableRelationWhere(where: Record<string, unknown>, target: str
|
|
|
260
260
|
for (const [k, v] of Object.entries(where)) {
|
|
261
261
|
if (k === "AND" || k === "OR") {
|
|
262
262
|
for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
|
|
263
|
+
} else if (k === "NOT") {
|
|
264
|
+
assertReadableRelationWhere(v as Record<string, unknown>, target, fields, ctx);
|
|
263
265
|
} else if (targetRels[k]) {
|
|
264
266
|
continue;
|
|
265
267
|
} else if (!fields.includes(k)) {
|
|
@@ -300,7 +302,15 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
300
302
|
|
|
301
303
|
// belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
|
|
302
304
|
// hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
|
|
303
|
-
|
|
305
|
+
// manyToMany: parent.<pk> IN (SELECT <sourceCol> FROM through
|
|
306
|
+
// WHERE <targetCol> IN (SELECT <target pk> FROM target WHERE inner))
|
|
307
|
+
if (rel.kind === "manyToMany") {
|
|
308
|
+
const targetSub: SqlExpr = { t: "sub", outerCol: rel.targetColumn, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false };
|
|
309
|
+
return { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.through, selectCol: rel.sourceColumn, where: targetSub, negate: false };
|
|
310
|
+
}
|
|
311
|
+
// oneHasOne mirrors belongsTo (this row's FK → target pk); oneHasOneInverse mirrors
|
|
312
|
+
// hasMany (target's column → this row's pk).
|
|
313
|
+
return rel.kind === "belongsTo" || rel.kind === "oneHasOne"
|
|
304
314
|
? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
|
|
305
315
|
: { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
|
|
306
316
|
}
|
|
@@ -336,6 +346,8 @@ export function compileScopedWhere(
|
|
|
336
346
|
if (k === "AND" || k === "OR") {
|
|
337
347
|
const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
|
|
338
348
|
parts.push(k === "AND" ? and(...groups) : or(...groups));
|
|
349
|
+
} else if (k === "NOT") {
|
|
350
|
+
parts.push(not(compileScopedWhere(v as Record<string, unknown>, entity, ctx, depth, allowRelations)));
|
|
339
351
|
} else if (relations[k]) {
|
|
340
352
|
if (!allowRelations) {
|
|
341
353
|
throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
|
package/src/runtime/db.ts
CHANGED
|
@@ -412,6 +412,9 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
412
412
|
case "or":
|
|
413
413
|
for (const p of expr.parts) this.addTouchedTables(p);
|
|
414
414
|
break;
|
|
415
|
+
case "not":
|
|
416
|
+
this.addTouchedTables(expr.expr);
|
|
417
|
+
break;
|
|
415
418
|
}
|
|
416
419
|
}
|
|
417
420
|
|
|
@@ -427,6 +430,8 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
427
430
|
for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
|
|
428
431
|
if (k === "AND" || k === "OR") {
|
|
429
432
|
for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
|
|
433
|
+
} else if (k === "NOT") {
|
|
434
|
+
this.assertReadableWhere(from, scope, v);
|
|
430
435
|
} else if (relations[k]) {
|
|
431
436
|
continue; // relation traversal: enforced against the target's scope downstream
|
|
432
437
|
} else if (!scope.fields.includes(k)) {
|
|
@@ -636,12 +641,44 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
636
641
|
return rows.map((row) => ({ key: row[col], row: project(row) }));
|
|
637
642
|
};
|
|
638
643
|
|
|
639
|
-
if (rel.kind === "belongsTo") {
|
|
640
|
-
// parent[column] -> target.id
|
|
644
|
+
if (rel.kind === "belongsTo" || rel.kind === "oneHasOne") {
|
|
645
|
+
// parent[column] -> target.id (single-valued; oneHasOne is belongsTo + a 1:1 unique)
|
|
641
646
|
const keys = [...new Set(rows.map((r) => r[rel.column]).filter((v) => v != null))];
|
|
642
647
|
const byId = new Map<unknown, Row>();
|
|
643
648
|
for (const { key, row } of await fetchBy(this.pkOf(rel.target), keys)) byId.set(key, row);
|
|
644
649
|
for (const r of rows) r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
|
|
650
|
+
} else if (rel.kind === "oneHasOneInverse") {
|
|
651
|
+
// inverse 1:1 — target[column] -> parent.<pk>, single object (or null)
|
|
652
|
+
const pk = this.pkOf(parentEntity);
|
|
653
|
+
const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
|
|
654
|
+
const byParent = new Map<unknown, Row>();
|
|
655
|
+
for (const { key, row } of await fetchBy(rel.column, ids)) if (!byParent.has(key)) byParent.set(key, row);
|
|
656
|
+
for (const r of rows) r[relName] = byParent.get(r[pk]) ?? null;
|
|
657
|
+
} else if (rel.kind === "manyToMany") {
|
|
658
|
+
// parent.<pk> -> junction(sourceColumn -> targetColumn) -> target.<pk>. The junction
|
|
659
|
+
// is read for just its two link columns (its own ACL isn't applied — like hasMany's
|
|
660
|
+
// intermediate); the target rows ARE scope-filtered by fetchBy, so an unreadable
|
|
661
|
+
// target simply drops out of the list.
|
|
662
|
+
const pk = this.pkOf(parentEntity);
|
|
663
|
+
const parentIds = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
|
|
664
|
+
if (parentIds.length === 0) {
|
|
665
|
+
for (const r of rows) r[relName] = [];
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
this.touched.add(rel.through);
|
|
669
|
+
const linkSel = compileSelect({ from: rel.through, where: inList(rel.sourceColumn, parentIds) }, this.dialect);
|
|
670
|
+
const links = this.decodeRows(rel.through, await this.driver.exec(linkSel.sql, linkSel.params));
|
|
671
|
+
const targetIds = [...new Set(links.map((l) => l[rel.targetColumn]).filter((v) => v != null))];
|
|
672
|
+
const byTarget = new Map<unknown, Row>();
|
|
673
|
+
for (const { key, row } of await fetchBy(this.pkOf(rel.target), targetIds)) byTarget.set(key, row);
|
|
674
|
+
const grouped = new Map<unknown, Row[]>();
|
|
675
|
+
for (const l of links) {
|
|
676
|
+
const t = byTarget.get(l[rel.targetColumn]);
|
|
677
|
+
if (!t) continue; // target unreadable or missing -> excluded from the list
|
|
678
|
+
const src = l[rel.sourceColumn];
|
|
679
|
+
(grouped.get(src) ?? grouped.set(src, []).get(src)!).push(t);
|
|
680
|
+
}
|
|
681
|
+
for (const r of rows) r[relName] = grouped.get(r[pk]) ?? [];
|
|
645
682
|
} else {
|
|
646
683
|
// hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
|
|
647
684
|
// slug/username would otherwise join on an undefined `r.id` and get []).
|
package/src/runtime/ddl.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// for a new column. Runs in TS inside the isolate; see runtime/migrate.ts for how
|
|
3
3
|
// these are applied.
|
|
4
4
|
|
|
5
|
-
import type { DefaultValue, EntityFields, FieldDef } from "../sdk/schema";
|
|
5
|
+
import type { DefaultValue, EntityFields, FieldDef, RelationDefs } from "../sdk/schema";
|
|
6
6
|
import { quoteIdent } from "./driver";
|
|
7
7
|
|
|
8
8
|
// SQLite has no boolean type; store as INTEGER 0/1. json + fileRef + uuid are
|
|
@@ -57,9 +57,47 @@ function columnSql(name: string, f: FieldDef): string {
|
|
|
57
57
|
return s;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/** Table-level FOREIGN KEY clauses for an entity's owning relations. A real FK is emitted
|
|
61
|
+
* ONLY for a `belongsTo`/`oneHasOne` that declares `onDelete` — so FKs are opt-in and
|
|
62
|
+
* pre-existing logical relations are unaffected (no retroactive constraint on data). `pkOf`
|
|
63
|
+
* resolves the referenced entity's primary-key column. `skip` omits specific FK columns —
|
|
64
|
+
* the migrator uses it to drop an FK whose existing data has orphaned references. */
|
|
65
|
+
export function foreignKeyClauses(
|
|
66
|
+
def: { relations?: RelationDefs },
|
|
67
|
+
pkOf: (entity: string) => string,
|
|
68
|
+
skip?: ReadonlySet<string>,
|
|
69
|
+
): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
for (const rel of Object.values(def.relations ?? {})) {
|
|
72
|
+
if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined) continue; // FK only when onDelete is declared
|
|
73
|
+
if (skip?.has(rel.column)) continue;
|
|
74
|
+
const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
|
|
75
|
+
out.push(`FOREIGN KEY (${quoteIdent(rel.column)}) REFERENCES ${quoteIdent(rel.target)}(${quoteIdent(pkOf(rel.target))}) ON DELETE ${action}`);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The FK columns an entity declares (belongsTo with onDelete) → their {target, action}.
|
|
81
|
+
* Used by the migrator to compare declared FKs against the live `foreign_key_list`. */
|
|
82
|
+
export function declaredForeignKeys(def: { relations?: RelationDefs }): Map<string, { target: string; onDelete: string }> {
|
|
83
|
+
const out = new Map<string, { target: string; onDelete: string }>();
|
|
84
|
+
for (const rel of Object.values(def.relations ?? {})) {
|
|
85
|
+
if ((rel.kind !== "belongsTo" && rel.kind !== "oneHasOne") || rel.onDelete === undefined) continue;
|
|
86
|
+
const action = rel.onDelete === "cascade" ? "CASCADE" : rel.onDelete === "setNull" ? "SET NULL" : "RESTRICT";
|
|
87
|
+
out.set(rel.column, { target: rel.target, onDelete: action });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createTableSql(
|
|
93
|
+
table: string,
|
|
94
|
+
def: { fields: EntityFields; relations?: RelationDefs },
|
|
95
|
+
pkOf?: (entity: string) => string,
|
|
96
|
+
skipFks?: ReadonlySet<string>,
|
|
97
|
+
): string {
|
|
61
98
|
const cols = Object.entries(def.fields).map(([n, f]) => columnSql(n, f));
|
|
62
|
-
|
|
99
|
+
const fks = pkOf ? foreignKeyClauses(def, pkOf, skipFks) : [];
|
|
100
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (${[...cols, ...fks].join(", ")})`;
|
|
63
101
|
}
|
|
64
102
|
|
|
65
103
|
/** Column definition for ALTER TABLE ADD COLUMN. No PRIMARY KEY / AUTOINCREMENT.
|
|
@@ -77,12 +115,31 @@ export function indexName(table: string, col: string): string {
|
|
|
77
115
|
return `pramen_idx_${table}_${col}`;
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
/** Index name for a composite (multi-column) UNIQUE constraint. The `pramen_uidx_`
|
|
119
|
+
* prefix distinguishes managed composite uniques from single-column `pramen_idx_` ones,
|
|
120
|
+
* so the migrator can enumerate and reconcile just the ones it owns. */
|
|
121
|
+
export function compositeUniqueName(table: string, cols: readonly string[]): string {
|
|
122
|
+
return `pramen_uidx_${table}_${cols.join("_")}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Canonical key for a composite-unique column tuple (order-significant, matching the
|
|
126
|
+
* index definition). Used to compare declared vs live composite uniques. */
|
|
127
|
+
export function compositeKey(cols: readonly string[]): string {
|
|
128
|
+
return cols.join(",");
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
/** CREATE [UNIQUE] INDEX statements for a table's unique/index columns (idempotent
|
|
81
132
|
* via IF NOT EXISTS). Unique wins if a column declares both. `skipCols` omits specific
|
|
82
133
|
* columns — the migrator uses it to avoid emitting a UNIQUE index that would throw
|
|
83
134
|
* (duplicate values present on a column that just gained `unique()`); that delta is
|
|
84
|
-
* reported as skipped instead.
|
|
85
|
-
|
|
135
|
+
* reported as skipped instead. Entity-level composite uniques (`def.uniques`) are
|
|
136
|
+
* emitted too; `skipUniques` omits specific tuples (keyed by {@link compositeKey}). */
|
|
137
|
+
export function indexStatements(
|
|
138
|
+
table: string,
|
|
139
|
+
def: { fields: EntityFields; uniques?: readonly (readonly string[])[] },
|
|
140
|
+
skipCols?: ReadonlySet<string>,
|
|
141
|
+
skipUniques?: ReadonlySet<string>,
|
|
142
|
+
): string[] {
|
|
86
143
|
const out: string[] = [];
|
|
87
144
|
for (const [col, f] of Object.entries(def.fields)) {
|
|
88
145
|
if (!f.unique && !f.index) continue;
|
|
@@ -90,5 +147,10 @@ export function indexStatements(table: string, def: { fields: EntityFields }, sk
|
|
|
90
147
|
const kind = f.unique ? "UNIQUE INDEX" : "INDEX";
|
|
91
148
|
out.push(`CREATE ${kind} IF NOT EXISTS ${quoteIdent(indexName(table, col))} ON ${quoteIdent(table)} (${quoteIdent(col)})`);
|
|
92
149
|
}
|
|
150
|
+
for (const cols of def.uniques ?? []) {
|
|
151
|
+
if (cols.length === 0 || skipUniques?.has(compositeKey(cols))) continue;
|
|
152
|
+
const colList = cols.map((c) => quoteIdent(c)).join(", ");
|
|
153
|
+
out.push(`CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdent(compositeUniqueName(table, cols))} ON ${quoteIdent(table)} (${colList})`);
|
|
154
|
+
}
|
|
93
155
|
return out;
|
|
94
156
|
}
|
package/src/runtime/driver.ts
CHANGED
|
@@ -69,6 +69,12 @@ export interface Driver {
|
|
|
69
69
|
exec(sql: string, params: unknown[]): Promise<Row[]>;
|
|
70
70
|
/** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
|
|
71
71
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
72
|
+
/** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
|
|
73
|
+
* end — for a table rebuild that involves foreign keys (drop + recreate would trip an
|
|
74
|
+
* immediate FK check). Optional: absent on a driver whose migrate already runs inside a
|
|
75
|
+
* transaction (the DO), where the migrator falls back to sequential exec. Provided by the
|
|
76
|
+
* D1 driver (no interactive transactions — uses db.batch(), itself atomic). */
|
|
77
|
+
batch?(statements: ReadonlyArray<{ sql: string; params: unknown[] }>): Promise<void>;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/** DO SQLite — the in-process store. `SqlStorage` is synchronous; we wrap it as an
|
|
@@ -135,4 +141,15 @@ export class D1Driver implements Driver {
|
|
|
135
141
|
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
136
142
|
return fn();
|
|
137
143
|
}
|
|
144
|
+
|
|
145
|
+
/** D1's one atomic primitive: `session.batch()` runs the statements in a single
|
|
146
|
+
* transaction (rolled back as a unit on failure). Prepend `defer_foreign_keys` so a
|
|
147
|
+
* table rebuild's transient FK violations are checked only at the batch's commit. */
|
|
148
|
+
async batch(statements: ReadonlyArray<{ sql: string; params: unknown[] }>): Promise<void> {
|
|
149
|
+
const prepared = [
|
|
150
|
+
this.session.prepare("PRAGMA defer_foreign_keys = ON"),
|
|
151
|
+
...statements.map((s) => (s.params.length ? this.session.prepare(s.sql).bind(...s.params) : this.session.prepare(s.sql))),
|
|
152
|
+
];
|
|
153
|
+
await this.session.batch(prepared);
|
|
154
|
+
}
|
|
138
155
|
}
|