@jxsuite/connector 0.1.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 +78 -0
- package/jx-extension.json +20 -0
- package/package.json +72 -0
- package/schemas/project.fragment.schema.json +122 -0
- package/src/Connections.class.json +29 -0
- package/src/D1.class.json +94 -0
- package/src/Data.class.json +106 -0
- package/src/Sqlite.class.json +81 -0
- package/src/Supabase.class.json +81 -0
- package/src/TableDelete.class.json +56 -0
- package/src/TableEntry.class.json +65 -0
- package/src/TableInsert.class.json +59 -0
- package/src/TableQuery.class.json +75 -0
- package/src/TableUpdate.class.json +65 -0
- package/src/columns.ts +292 -0
- package/src/connectors.ts +65 -0
- package/src/d1.ts +64 -0
- package/src/ddl.ts +201 -0
- package/src/dialects/bun-sqlite.ts +130 -0
- package/src/dialects/d1-http.ts +113 -0
- package/src/provider-utils.ts +86 -0
- package/src/query.ts +199 -0
- package/src/sqlite.ts +51 -0
- package/src/supabase.ts +81 -0
- package/src/table-actions.ts +156 -0
- package/src/table-delete.ts +28 -0
- package/src/table-insert.ts +29 -0
- package/src/table-node.ts +111 -0
- package/src/table-shared.ts +131 -0
- package/src/table-state.ts +134 -0
- package/src/table-update.ts +29 -0
- package/src/types.ts +139 -0
- package/src/validate.ts +193 -0
- package/src/worker.ts +612 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-actions — shared machinery behind TableInsert / TableUpdate / TableDelete.
|
|
3
|
+
*
|
|
4
|
+
* The action classes lower to inline `Function` defs in compiled sites: an event handler that reads
|
|
5
|
+
* the submitting form's FormData (merged under the def's literal `values`), fetches the /_jx/data
|
|
6
|
+
* route, and on success bumps `state._v` — the read-after-write convention every lowered TableQuery
|
|
7
|
+
* URL subscribes to (see table-shared.ts). In the interpreter (dev canvas) the classes resolve to
|
|
8
|
+
* an equivalent real function.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { jsObjectSource, jsValueSource, resolveIdValue } from "./table-shared.ts";
|
|
12
|
+
import type { LowerContext } from "./table-state.ts";
|
|
13
|
+
|
|
14
|
+
export type ActionMethod = "POST" | "PATCH" | "DELETE";
|
|
15
|
+
|
|
16
|
+
/** The def shape all three action classes accept. */
|
|
17
|
+
export interface ActionDef {
|
|
18
|
+
table?: string;
|
|
19
|
+
id?: unknown;
|
|
20
|
+
/** Literal payload columns merged over the submitting form's FormData. */
|
|
21
|
+
values?: Record<string, unknown>;
|
|
22
|
+
_document?: { route?: { _pathParams?: Record<string, string> } };
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build the /_jx/data URL JS-source expression for a lowered action. */
|
|
27
|
+
function urlSource(method: ActionMethod, def: ActionDef, context: LowerContext): string {
|
|
28
|
+
const table = def.table ?? "";
|
|
29
|
+
if (method === "POST") {
|
|
30
|
+
return jsValueSource(`/_jx/data/${table}`);
|
|
31
|
+
}
|
|
32
|
+
const id = resolveIdValue(def.id, context.route?._pathParams) ?? "";
|
|
33
|
+
return jsValueSource(`/_jx/data/${table}/${id}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lower an action def into an inline `Function` def (specs/extensions.md §8.3).
|
|
38
|
+
*
|
|
39
|
+
* @param {ActionMethod} method
|
|
40
|
+
* @param {ActionDef} def
|
|
41
|
+
* @param {LowerContext} [context]
|
|
42
|
+
* @returns {Record<string, unknown>} A core Function def with an `event` parameter
|
|
43
|
+
*/
|
|
44
|
+
export function lowerActionDef(
|
|
45
|
+
method: ActionMethod,
|
|
46
|
+
def: ActionDef,
|
|
47
|
+
context: LowerContext = {},
|
|
48
|
+
): Record<string, unknown> {
|
|
49
|
+
const url = urlSource(method, def, context);
|
|
50
|
+
const lines: string[] = [
|
|
51
|
+
"if (event && typeof event.preventDefault === 'function') event.preventDefault();",
|
|
52
|
+
];
|
|
53
|
+
if (method === "DELETE") {
|
|
54
|
+
lines.push(
|
|
55
|
+
`fetch(${url}, { method: 'DELETE' }).then(function (res) {`,
|
|
56
|
+
" if (res.ok) { state._v = (state._v || 0) + 1; }",
|
|
57
|
+
"});",
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(
|
|
61
|
+
"var form = event && event.target && event.target.tagName === 'FORM' ? event.target : " +
|
|
62
|
+
"((event && event.target && event.target.form) || null);",
|
|
63
|
+
"var payload = Object.assign({}, form ? Object.fromEntries(new FormData(form)) : {}, " +
|
|
64
|
+
`${jsObjectSource(def.values)});`,
|
|
65
|
+
`fetch(${url}, { method: '${method}', headers: { 'Content-Type': 'application/json' }, ` +
|
|
66
|
+
"body: JSON.stringify(payload) }).then(function (res) {",
|
|
67
|
+
" if (res.ok) {",
|
|
68
|
+
...(method === "POST"
|
|
69
|
+
? [" if (form && typeof form.reset === 'function') form.reset();"]
|
|
70
|
+
: []),
|
|
71
|
+
" state._v = (state._v || 0) + 1;",
|
|
72
|
+
" }",
|
|
73
|
+
"});",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
$prototype: "Function",
|
|
78
|
+
body: lines.join("\n"),
|
|
79
|
+
parameters: ["event"],
|
|
80
|
+
timing: "client",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Evaluate a `${...}`-bearing values object against a live scope (interpreter path). */
|
|
85
|
+
function evaluateValues(
|
|
86
|
+
values: Record<string, unknown> | undefined,
|
|
87
|
+
scope: Record<string, unknown>,
|
|
88
|
+
): Record<string, unknown> {
|
|
89
|
+
const out: Record<string, unknown> = {};
|
|
90
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
91
|
+
if (typeof value === "string" && value.includes("${")) {
|
|
92
|
+
try {
|
|
93
|
+
const fn = new Function("state", `return \`${value}\`;`) as (
|
|
94
|
+
state: Record<string, unknown>,
|
|
95
|
+
) => unknown;
|
|
96
|
+
out[key] = fn(scope);
|
|
97
|
+
} catch {
|
|
98
|
+
out[key] = value;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
out[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build the live handler an action class resolves to in the interpreter (dev canvas).
|
|
109
|
+
*
|
|
110
|
+
* @param {ActionMethod} method
|
|
111
|
+
* @param {ActionDef} config
|
|
112
|
+
* @returns {(scope: Record<string, unknown>, event?: Event) => Promise<boolean>} Resolves true when
|
|
113
|
+
* the write succeeded (and `scope._v` was bumped)
|
|
114
|
+
*/
|
|
115
|
+
export function makeActionHandler(method: ActionMethod, config: ActionDef) {
|
|
116
|
+
return async (scope: Record<string, unknown>, event?: Event): Promise<boolean> => {
|
|
117
|
+
if (event && typeof event.preventDefault === "function") {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
const table = config.table ?? "";
|
|
121
|
+
let url = `/_jx/data/${table}`;
|
|
122
|
+
if (method !== "POST") {
|
|
123
|
+
const id = resolveIdValue(config.id, config._document?.route?._pathParams) ?? "";
|
|
124
|
+
url = `${url}/${id}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let init: RequestInit;
|
|
128
|
+
let form: HTMLFormElement | null = null;
|
|
129
|
+
if (method === "DELETE") {
|
|
130
|
+
init = { method };
|
|
131
|
+
} else {
|
|
132
|
+
const target = event?.target as
|
|
133
|
+
| (HTMLElement & { form?: HTMLFormElement | null })
|
|
134
|
+
| null
|
|
135
|
+
| undefined;
|
|
136
|
+
form =
|
|
137
|
+
target && target.tagName === "FORM" ? (target as HTMLFormElement) : (target?.form ?? null);
|
|
138
|
+
const formData = form ? Object.fromEntries(new FormData(form)) : {};
|
|
139
|
+
const payload = { ...formData, ...evaluateValues(config.values, scope) };
|
|
140
|
+
init = {
|
|
141
|
+
body: JSON.stringify(payload),
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
method,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const response = await fetch(url, init);
|
|
148
|
+
if (response.ok) {
|
|
149
|
+
if (method === "POST" && form && typeof form.reset === "function") {
|
|
150
|
+
form.reset();
|
|
151
|
+
}
|
|
152
|
+
scope._v = ((scope._v as number) || 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
return response.ok;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-delete — the TableDelete action class (DELETE /_jx/data/:table/:id).
|
|
3
|
+
*
|
|
4
|
+
* No payload; the id resolves like TableUpdate's. On success `state._v` bumps so lowered queries
|
|
5
|
+
* refetch. See table-actions.ts for the shared machinery.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
|
|
9
|
+
import type { ActionDef } from "./table-actions.ts";
|
|
10
|
+
import type { LowerContext } from "./table-state.ts";
|
|
11
|
+
|
|
12
|
+
export class TableDelete {
|
|
13
|
+
config: ActionDef;
|
|
14
|
+
|
|
15
|
+
constructor(config: ActionDef) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve to the live delete handler (interpreter path). */
|
|
20
|
+
resolve() {
|
|
21
|
+
return makeActionHandler("DELETE", this.config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Static `lower` capability: compile away into an inline Function def. */
|
|
25
|
+
static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
|
|
26
|
+
return lowerActionDef("DELETE", def, context);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-insert — the TableInsert action class (POST /_jx/data/:table).
|
|
3
|
+
*
|
|
4
|
+
* Wire it to a form: `"onsubmit": { "$ref": "#/state/addComment" }`. The handler merges the form's
|
|
5
|
+
* FormData under the def's literal `values`, POSTs the row, resets the form, and bumps `state._v`
|
|
6
|
+
* so lowered queries refetch. See table-actions.ts for the shared machinery.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
|
|
10
|
+
import type { ActionDef } from "./table-actions.ts";
|
|
11
|
+
import type { LowerContext } from "./table-state.ts";
|
|
12
|
+
|
|
13
|
+
export class TableInsert {
|
|
14
|
+
config: ActionDef;
|
|
15
|
+
|
|
16
|
+
constructor(config: ActionDef) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve to the live insert handler (interpreter path). */
|
|
21
|
+
resolve() {
|
|
22
|
+
return makeActionHandler("POST", this.config);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Static `lower` capability: compile away into an inline Function def. */
|
|
26
|
+
static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
|
|
27
|
+
return lowerActionDef("POST", def, context);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-node — direct-database resolution for TableQuery/TableEntry under node/Bun.
|
|
3
|
+
*
|
|
4
|
+
* The dev server's resolve proxy and compiler-timing SSG bakes land here (browsers fetch /_jx/data
|
|
5
|
+
* instead — see table-state.ts). The connection resolves through the first-party provider map
|
|
6
|
+
* (`resolveDialect`); sqlite connections are additively auto-synced before the first read so a
|
|
7
|
+
* fresh project queries cleanly. Errors propagate — the state classes turn them into warnings +
|
|
8
|
+
* empty results.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Kysely } from "kysely";
|
|
12
|
+
import { fromStorage, planTable } from "./columns.ts";
|
|
13
|
+
import { resolveDialect } from "./connectors.ts";
|
|
14
|
+
import { syncTables } from "./ddl.ts";
|
|
15
|
+
import { applyFilter, applySort, normalizeFilter, normalizeSort } from "./query.ts";
|
|
16
|
+
import { tablesForConnection } from "./provider-utils.ts";
|
|
17
|
+
import type { SqlDialectKind } from "./columns.ts";
|
|
18
|
+
import type { ConnectionsConfig } from "./connectors.ts";
|
|
19
|
+
import type { DynamicDatabase } from "./query.ts";
|
|
20
|
+
import type { TableQueryDef } from "./table-shared.ts";
|
|
21
|
+
import type { TableDef } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
/** The `_project`-bearing config shape hosts inject into state classes. */
|
|
24
|
+
export interface TableNodeConfig extends TableQueryDef {
|
|
25
|
+
id?: unknown;
|
|
26
|
+
_project?: {
|
|
27
|
+
config?: ConnectionsConfig & { data?: Record<string, TableDef> };
|
|
28
|
+
root?: string;
|
|
29
|
+
data?: Record<string, TableDef>;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface OpenedTable {
|
|
35
|
+
db: Kysely<DynamicDatabase>;
|
|
36
|
+
kind: SqlDialectKind;
|
|
37
|
+
tables: Record<string, TableDef>;
|
|
38
|
+
tableName: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Open the table's connection, auto-syncing local sqlite databases additively. */
|
|
42
|
+
async function openTable(config: TableNodeConfig): Promise<OpenedTable> {
|
|
43
|
+
const project = config._project ?? {};
|
|
44
|
+
const projectConfig = project.config ?? {};
|
|
45
|
+
const tables = projectConfig.data ?? project.data ?? {};
|
|
46
|
+
const tableName = config.table ?? "";
|
|
47
|
+
const tableDef = tables[tableName];
|
|
48
|
+
if (!tableDef) {
|
|
49
|
+
throw new Error(`unknown table "${tableName}" (no data section entry)`);
|
|
50
|
+
}
|
|
51
|
+
const env: Record<string, unknown> = {
|
|
52
|
+
...process.env,
|
|
53
|
+
...(project.root === undefined ? {} : { JX_PROJECT_ROOT: project.root }),
|
|
54
|
+
};
|
|
55
|
+
const { dialect, type } = await resolveDialect(tableDef.connection, projectConfig, env);
|
|
56
|
+
const db = new Kysely<DynamicDatabase>({ dialect });
|
|
57
|
+
const connection = projectConfig.connections?.[tableDef.connection];
|
|
58
|
+
if (connection?.provider === "sqlite") {
|
|
59
|
+
// Additive-only and idempotent — a fresh local database materializes on first read.
|
|
60
|
+
await syncTables(db, tablesForConnection(tables, tableDef.connection), { dialect: type });
|
|
61
|
+
}
|
|
62
|
+
return { db, kind: type, tableName, tables };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Query a table with the def's filter/sort/limit/offset (the content grammar).
|
|
67
|
+
*
|
|
68
|
+
* @param {TableNodeConfig} config
|
|
69
|
+
* @returns {Promise<Record<string, unknown>[]>}
|
|
70
|
+
*/
|
|
71
|
+
export async function queryTable(config: TableNodeConfig): Promise<Record<string, unknown>[]> {
|
|
72
|
+
const { db, kind, tables, tableName } = await openTable(config);
|
|
73
|
+
try {
|
|
74
|
+
const plan = planTable(tableName, tables[tableName]!, tables, kind);
|
|
75
|
+
let qb = db.selectFrom(tableName).selectAll();
|
|
76
|
+
qb = applyFilter(qb, normalizeFilter(config.filter), plan.columns);
|
|
77
|
+
qb = applySort(qb, normalizeSort(config.sort), plan.columns);
|
|
78
|
+
if (typeof config.limit === "number" && config.limit > 0) {
|
|
79
|
+
qb = qb.limit(config.limit);
|
|
80
|
+
}
|
|
81
|
+
if (typeof config.offset === "number" && config.offset > 0) {
|
|
82
|
+
qb = qb.limit(config.limit ?? 100_000_000).offset(config.offset);
|
|
83
|
+
}
|
|
84
|
+
const rows = await qb.execute();
|
|
85
|
+
return rows.map((row) => fromStorage(plan.columns, row));
|
|
86
|
+
} finally {
|
|
87
|
+
await db.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch one row by id, or null.
|
|
93
|
+
*
|
|
94
|
+
* @param {TableNodeConfig} config
|
|
95
|
+
* @param {string} id
|
|
96
|
+
* @returns {Promise<Record<string, unknown> | null>}
|
|
97
|
+
*/
|
|
98
|
+
export async function getEntry(
|
|
99
|
+
config: TableNodeConfig,
|
|
100
|
+
id: string,
|
|
101
|
+
): Promise<Record<string, unknown> | null> {
|
|
102
|
+
const { db, kind, tables, tableName } = await openTable(config);
|
|
103
|
+
try {
|
|
104
|
+
const plan = planTable(tableName, tables[tableName]!, tables, kind);
|
|
105
|
+
const key = plan.idType === "integer" ? Number(id) : id;
|
|
106
|
+
const row = await db.selectFrom(tableName).selectAll().where("id", "=", key).executeTakeFirst();
|
|
107
|
+
return row ? fromStorage(plan.columns, row) : null;
|
|
108
|
+
} finally {
|
|
109
|
+
await db.destroy();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-shared — pure helpers behind the table state classes and their `lower` capability.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is browser-safe string building: /_jx/data URL construction with
|
|
5
|
+
* template-placeholder preservation, `#/$params/` id resolution, and the JS-source emission used by
|
|
6
|
+
* lowered action bodies.
|
|
7
|
+
*
|
|
8
|
+
* Cache-busting convention (`_v`): lowered TableQuery Requests append `_v=${(state._v || 0)}` to
|
|
9
|
+
* their URL, and lowered write actions end with `state._v = (state._v || 0) + 1`. The compiled
|
|
10
|
+
* Request effect re-runs whenever a template-referenced signal changes, so bumping `_v` after a
|
|
11
|
+
* successful write refetches every query on the page — read-after-write without any bespoke runtime
|
|
12
|
+
* machinery.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A `${...}` template span (Jx binding syntax preserved through lowering). */
|
|
16
|
+
const TEMPLATE_SPAN = /(\$\{[^}]*\})/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Percent-encode a query value while leaving `${...}` template spans intact, so the runtime's
|
|
20
|
+
* template evaluation still sees them after lowering.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} value
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function encodeQueryValue(value: string): string {
|
|
26
|
+
return value
|
|
27
|
+
.split(TEMPLATE_SPAN)
|
|
28
|
+
.map((part) => (TEMPLATE_SPAN.test(part) ? part : encodeURIComponent(part)))
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The wire-facing query options a TableQuery/TableEntry def may carry. */
|
|
33
|
+
export interface TableQueryDef {
|
|
34
|
+
table?: string;
|
|
35
|
+
filter?: unknown;
|
|
36
|
+
sort?: unknown;
|
|
37
|
+
limit?: number;
|
|
38
|
+
offset?: number;
|
|
39
|
+
include?: string | string[];
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a /_jx/data URL for a query def, preserving template placeholders.
|
|
45
|
+
*
|
|
46
|
+
* @param {TableQueryDef} def
|
|
47
|
+
* @param {{ id?: string; versionParam?: boolean }} [options] - `id` appends a row path segment;
|
|
48
|
+
* `versionParam` appends the `_v` cache-busting template (lowered Requests only)
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function buildDataUrl(
|
|
52
|
+
def: TableQueryDef,
|
|
53
|
+
options: { id?: string; versionParam?: boolean } = {},
|
|
54
|
+
): string {
|
|
55
|
+
const table = def.table ?? "";
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
if (def.filter !== undefined && def.filter !== null) {
|
|
58
|
+
parts.push(`filter=${encodeQueryValue(JSON.stringify(def.filter))}`);
|
|
59
|
+
}
|
|
60
|
+
if (def.sort !== undefined && def.sort !== null) {
|
|
61
|
+
parts.push(`sort=${encodeQueryValue(JSON.stringify(def.sort))}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof def.limit === "number") {
|
|
64
|
+
parts.push(`limit=${def.limit}`);
|
|
65
|
+
}
|
|
66
|
+
if (typeof def.offset === "number") {
|
|
67
|
+
parts.push(`offset=${def.offset}`);
|
|
68
|
+
}
|
|
69
|
+
const include = Array.isArray(def.include) ? def.include.join(",") : def.include;
|
|
70
|
+
if (include) {
|
|
71
|
+
parts.push(`include=${encodeQueryValue(include)}`);
|
|
72
|
+
}
|
|
73
|
+
if (options.versionParam) {
|
|
74
|
+
// Read-after-write: writes bump state._v, re-running the compiled Request effect.
|
|
75
|
+
parts.push("_v=${(state._v || 0)}");
|
|
76
|
+
}
|
|
77
|
+
const path = options.id === undefined ? `/_jx/data/${table}` : `/_jx/data/${table}/${options.id}`;
|
|
78
|
+
return parts.length > 0 ? `${path}?${parts.join("&")}` : path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve an id value: `#/$params/<name>` refs read route params, everything else passes through as
|
|
83
|
+
* a string (template placeholders included).
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} id - The def's id value
|
|
86
|
+
* @param {Record<string, string> | undefined} pathParams - Route params when known
|
|
87
|
+
* @returns {string | undefined}
|
|
88
|
+
*/
|
|
89
|
+
export function resolveIdValue(
|
|
90
|
+
id: unknown,
|
|
91
|
+
pathParams: Record<string, string> | undefined,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
if (id === null || id === undefined) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (typeof id === "object") {
|
|
97
|
+
const ref = (id as { $ref?: string }).$ref;
|
|
98
|
+
if (typeof ref === "string" && ref.startsWith("#/$params/")) {
|
|
99
|
+
const param = ref.slice("#/$params/".length);
|
|
100
|
+
return pathParams?.[param];
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
return String(id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Emit the JS source for a literal value inside a lowered Function body. Strings carrying `${...}`
|
|
109
|
+
* become template literals (evaluated against state at runtime); everything else is inlined JSON.
|
|
110
|
+
*
|
|
111
|
+
* @param {unknown} value
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function jsValueSource(value: unknown): string {
|
|
115
|
+
if (typeof value === "string" && value.includes("${")) {
|
|
116
|
+
const escaped = value.replaceAll("\\", String.raw`\\`).replaceAll("`", "\\`");
|
|
117
|
+
return `\`${escaped}\``;
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(value) ?? "null";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Emit the JS source of an object literal whose values go through {@link jsValueSource}. */
|
|
123
|
+
export function jsObjectSource(values: Record<string, unknown> | undefined): string {
|
|
124
|
+
if (!values || Object.keys(values).length === 0) {
|
|
125
|
+
return "{}";
|
|
126
|
+
}
|
|
127
|
+
const entries = Object.entries(values).map(
|
|
128
|
+
([key, value]) => `${JSON.stringify(key)}: ${jsValueSource(value)}`,
|
|
129
|
+
);
|
|
130
|
+
return `{ ${entries.join(", ")} }`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-state — the TableQuery and TableEntry state classes.
|
|
3
|
+
*
|
|
4
|
+
* Both are ordinary external classes (constructor + resolve) with a static `lower` capability
|
|
5
|
+
* (specs/extensions.md §8.3). In browsers, resolve() fetches the live /_jx/data routes (dev server
|
|
6
|
+
* and deployed workers serve the same wire contract). Under node/Bun (dev-server resolve proxy,
|
|
7
|
+
* compiler-timing SSG bakes) it reaches the database directly through the node-only sibling module,
|
|
8
|
+
* loaded through an opaque specifier so browser bundles never pull kysely dialects in. When the
|
|
9
|
+
* database is unreachable at compile time (e.g. D1 without CLOUDFLARE_API_TOKEN) the bake degrades
|
|
10
|
+
* to an empty result with a warning, never a failed build.
|
|
11
|
+
*
|
|
12
|
+
* Lowering: in compiled sites a `timing: "client"` TableQuery becomes a core `Request` def whose
|
|
13
|
+
* URL carries the query params (template placeholders preserved) plus the `_v` read-after-write
|
|
14
|
+
* cache-busting param — see table-shared.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { buildDataUrl, resolveIdValue } from "./table-shared.ts";
|
|
18
|
+
import type { TableQueryDef } from "./table-shared.ts";
|
|
19
|
+
|
|
20
|
+
/** Route/document context injected by hosts under `_document`. */
|
|
21
|
+
interface DocumentContext {
|
|
22
|
+
route?: { _pathParams?: Record<string, string> };
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The context object hosts pass to `lower(def, context)`. */
|
|
27
|
+
export interface LowerContext {
|
|
28
|
+
route?: { _pathParams?: Record<string, string>; sourcePath?: string };
|
|
29
|
+
projectConfig?: Record<string, unknown>;
|
|
30
|
+
root?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface TableStateConfig extends TableQueryDef {
|
|
34
|
+
id?: unknown;
|
|
35
|
+
_document?: DocumentContext;
|
|
36
|
+
_project?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when running in a browser (canvas/client); node hosts take the direct-database path. */
|
|
40
|
+
function inBrowser(): boolean {
|
|
41
|
+
return typeof document !== "undefined" && typeof fetch === "function";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The surface of the node-only sibling module (see table-node.ts). */
|
|
45
|
+
interface TableNodeModule {
|
|
46
|
+
queryTable: (config: TableStateConfig) => Promise<unknown>;
|
|
47
|
+
getEntry: (config: TableStateConfig, id: string) => Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Import the node-only sibling through an opaque specifier (bundlers must not follow it). */
|
|
51
|
+
async function nodeModule(): Promise<TableNodeModule> {
|
|
52
|
+
const specifier = "./table-node.ts";
|
|
53
|
+
return (await import(specifier)) as TableNodeModule;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class TableQuery {
|
|
57
|
+
config: TableStateConfig;
|
|
58
|
+
|
|
59
|
+
constructor(config: TableStateConfig) {
|
|
60
|
+
this.config = config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rows matching the def's filter/sort/limit — over HTTP in browsers, directly under node. */
|
|
64
|
+
async resolve(): Promise<unknown> {
|
|
65
|
+
if (inBrowser()) {
|
|
66
|
+
const response = await fetch(buildDataUrl(this.config));
|
|
67
|
+
return response.ok ? await response.json() : [];
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const { queryTable } = await nodeModule();
|
|
71
|
+
return await queryTable(this.config);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
console.warn(`TableQuery("${this.config.table}") could not reach the database: ${message}`);
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Static `lower` capability: compile away into a core Request def (client fetch). */
|
|
80
|
+
static lower(def: Record<string, unknown>): Record<string, unknown> {
|
|
81
|
+
return {
|
|
82
|
+
$prototype: "Request",
|
|
83
|
+
timing: "client",
|
|
84
|
+
url: buildDataUrl(def as TableQueryDef, { versionParam: true }),
|
|
85
|
+
...(def.default === undefined ? { default: [] } : { default: def.default }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class TableEntry {
|
|
91
|
+
config: TableStateConfig;
|
|
92
|
+
|
|
93
|
+
constructor(config: TableStateConfig) {
|
|
94
|
+
this.config = config;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One row by id (`#/$params/` refs resolve against the route), or null. */
|
|
98
|
+
async resolve(): Promise<unknown> {
|
|
99
|
+
const { _document } = this.config;
|
|
100
|
+
const id = resolveIdValue(this.config.id, _document?.route?._pathParams);
|
|
101
|
+
if (id === undefined || id === "") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (inBrowser()) {
|
|
105
|
+
const response = await fetch(buildDataUrl(this.config, { id }));
|
|
106
|
+
return response.ok ? await response.json() : null;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const { getEntry } = await nodeModule();
|
|
110
|
+
return await getEntry(this.config, id);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
console.warn(`TableEntry("${this.config.table}") could not reach the database: ${message}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Static `lower` capability: compile away into a core Request def for one row. */
|
|
119
|
+
static lower(def: Record<string, unknown>, context: LowerContext = {}): Record<string, unknown> {
|
|
120
|
+
const id = resolveIdValue(def.id, context.route?._pathParams);
|
|
121
|
+
if (id === undefined) {
|
|
122
|
+
console.warn(
|
|
123
|
+
`TableEntry("${String(def.table)}"): id did not resolve at lower time — ` +
|
|
124
|
+
`the compiled Request will fetch an empty id`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
$prototype: "Request",
|
|
129
|
+
timing: "client",
|
|
130
|
+
url: buildDataUrl(def as TableQueryDef, { id: id ?? "", versionParam: true }),
|
|
131
|
+
...(def.default === undefined ? { default: null } : { default: def.default }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-update — the TableUpdate action class (PATCH /_jx/data/:table/:id).
|
|
3
|
+
*
|
|
4
|
+
* The id may be a literal, a `${...}` template, or `{ "$ref": "#/$params/<name>" }` (resolved
|
|
5
|
+
* against the route at lower time). Payload = form FormData merged under the def's `values`. See
|
|
6
|
+
* table-actions.ts for the shared machinery.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
|
|
10
|
+
import type { ActionDef } from "./table-actions.ts";
|
|
11
|
+
import type { LowerContext } from "./table-state.ts";
|
|
12
|
+
|
|
13
|
+
export class TableUpdate {
|
|
14
|
+
config: ActionDef;
|
|
15
|
+
|
|
16
|
+
constructor(config: ActionDef) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve to the live update handler (interpreter path). */
|
|
21
|
+
resolve() {
|
|
22
|
+
return makeActionHandler("PATCH", this.config);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Static `lower` capability: compile away into an inline Function def. */
|
|
26
|
+
static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
|
|
27
|
+
return lowerActionDef("PATCH", def, context);
|
|
28
|
+
}
|
|
29
|
+
}
|