@jxsuite/connector 0.6.1 → 0.6.3

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/src/sqlite.ts CHANGED
@@ -7,15 +7,17 @@
7
7
  * Supabase.
8
8
  */
9
9
 
10
+ import { isString } from "@jxsuite/schema/guards";
10
11
  import { createBunSqliteDialect } from "./dialects/bun-sqlite.ts";
11
12
  import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
12
13
  import type { Dialect } from "kysely";
13
- import type { ConnectorProvider } from "./provider-utils.ts";
14
+ import type { ConnectorProvider, HostEnv } from "./provider-utils.ts";
14
15
  import type { ConnectionDef } from "./types.ts";
15
16
 
16
17
  /** Resolve the SQLite file path for a connection. */
17
- export function sqliteFilePath(connection: ConnectionDef, env: Record<string, unknown>): string {
18
- const root = typeof env["JX_PROJECT_ROOT"] === "string" ? env["JX_PROJECT_ROOT"] : ".";
18
+ export function sqliteFilePath(connection: ConnectionDef, env: HostEnv): string {
19
+ const projectRoot = env["JX_PROJECT_ROOT"];
20
+ const root = isString(projectRoot) ? projectRoot : ".";
19
21
  const name = connection.$name ?? "default";
20
22
  const file = connection.file ?? `./.jx/data/${name}.sqlite`;
21
23
  if (file === ":memory:" || file.startsWith("/")) {
@@ -25,7 +27,7 @@ export function sqliteFilePath(connection: ConnectionDef, env: Record<string, un
25
27
  }
26
28
 
27
29
  /** Build the bun:sqlite dialect for a connection. */
28
- function sqliteDialect(connection: ConnectionDef, env: Record<string, unknown>): Dialect {
30
+ function sqliteDialect(connection: ConnectionDef, env: HostEnv): Dialect {
29
31
  return createBunSqliteDialect({ url: sqliteFilePath(connection, env) });
30
32
  }
31
33
 
@@ -34,14 +36,14 @@ export const Sqlite = {
34
36
  deploySchema: async (
35
37
  tables: Parameters<ConnectorProvider["deploySchema"]>[0],
36
38
  connection: ConnectionDef,
37
- options: { env: Record<string, unknown>; dryRun?: boolean },
39
+ options: { env: HostEnv; dryRun?: boolean },
38
40
  ) => {
39
41
  const dialect = sqliteDialect(connection, options.env);
40
42
  return deployWithDialect(dialect, "sqlite", tables, { dryRun: options.dryRun ?? false });
41
43
  },
42
44
  dialect: sqliteDialect,
43
45
  kind: "sqlite",
44
- testConnection: async (connection: ConnectionDef, env: Record<string, unknown>) => {
46
+ testConnection: async (connection: ConnectionDef, env: HostEnv) => {
45
47
  try {
46
48
  return await testWithDialect(sqliteDialect(connection, env));
47
49
  } catch (error) {
package/src/supabase.ts CHANGED
@@ -8,29 +8,27 @@
8
8
  * never touch a postgres connection never import them.
9
9
  */
10
10
 
11
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
11
12
  import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
12
13
  import type { Dialect } from "kysely";
13
- import type { ConnectorProvider } from "./provider-utils.ts";
14
+ import type { ConnectorProvider, HostEnv } from "./provider-utils.ts";
14
15
  import type { ConnectionDef } from "./types.ts";
15
16
 
16
17
  /**
17
18
  * Resolve the postgres URL for a connection: Hyperdrive binding first, then `urlEnv`.
18
19
  *
19
20
  * @param {ConnectionDef} connection
20
- * @param {Record<string, unknown>} env
21
+ * @param {HostEnv} env
21
22
  * @returns {string}
22
23
  */
23
- export function resolvePostgresUrl(
24
- connection: ConnectionDef,
25
- env: Record<string, unknown>,
26
- ): string {
24
+ export function resolvePostgresUrl(connection: ConnectionDef, env: HostEnv): string {
27
25
  const { binding, urlEnv, $name = "connection" } = connection;
28
- const bound = binding ? (env[binding] as { connectionString?: string } | undefined) : undefined;
29
- if (bound && typeof bound.connectionString === "string") {
26
+ const bound = binding ? env[binding] : undefined;
27
+ if (isNonNullObject(bound) && "connectionString" in bound && isString(bound.connectionString)) {
30
28
  return bound.connectionString;
31
29
  }
32
30
  const url = urlEnv ? env[urlEnv] : undefined;
33
- if (typeof url === "string" && url !== "") {
31
+ if (isString(url) && url !== "") {
34
32
  return url;
35
33
  }
36
34
  throw new Error(
@@ -62,15 +60,15 @@ export const Supabase = {
62
60
  deploySchema: async (
63
61
  tables: Parameters<ConnectorProvider["deploySchema"]>[0],
64
62
  connection: ConnectionDef,
65
- options: { env: Record<string, unknown>; dryRun?: boolean },
63
+ options: { env: HostEnv; dryRun?: boolean },
66
64
  ) => {
67
65
  const dialect = await postgresDialect(resolvePostgresUrl(connection, options.env));
68
66
  return deployWithDialect(dialect, "postgres", tables, { dryRun: options.dryRun ?? false });
69
67
  },
70
- dialect: (connection: ConnectionDef, env: Record<string, unknown>) =>
68
+ dialect: (connection: ConnectionDef, env: HostEnv) =>
71
69
  postgresDialect(resolvePostgresUrl(connection, env)),
72
70
  kind: "postgres",
73
- testConnection: async (connection: ConnectionDef, env: Record<string, unknown>) => {
71
+ testConnection: async (connection: ConnectionDef, env: HostEnv) => {
74
72
  try {
75
73
  const dialect = await postgresDialect(resolvePostgresUrl(connection, env));
76
74
  return await testWithDialect(dialect);
@@ -8,7 +8,9 @@
8
8
  * an equivalent real function.
9
9
  */
10
10
 
11
+ import { isFunction, isString } from "@jxsuite/schema/guards";
11
12
  import { jsObjectSource, jsValueSource, resolveIdValue } from "./table-shared.ts";
13
+ import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
12
14
  import type { LowerContext } from "./table-state.ts";
13
15
 
14
16
  export type ActionMethod = "POST" | "PATCH" | "DELETE";
@@ -16,13 +18,23 @@ export type ActionMethod = "POST" | "PATCH" | "DELETE";
16
18
  /** The def shape all three action classes accept. */
17
19
  export interface ActionDef {
18
20
  table?: string;
19
- id?: unknown;
21
+ id?: JsonValue;
20
22
  /** Literal payload columns merged over the submitting form's FormData. */
21
- values?: Record<string, unknown>;
23
+ values?: JsonObject;
22
24
  _document?: { route?: { _pathParams?: Record<string, string> } };
23
- [key: string]: unknown;
24
25
  }
25
26
 
27
+ /**
28
+ * The live state scope an action handler runs against (interpreter path): `${...}` values are
29
+ * evaluated against it as `state`, and a successful write bumps its `_v` read-after-write counter.
30
+ */
31
+ export interface ActionScope {
32
+ _v?: number;
33
+ }
34
+
35
+ /** A `values` entry compiled to a template function over the live state scope. */
36
+ type ValueTemplate = (state: ActionScope) => string;
37
+
26
38
  /** Build the /_jx/data URL JS-source expression for a lowered action. */
27
39
  function urlSource(method: ActionMethod, def: ActionDef, context: LowerContext): string {
28
40
  const table = def.table ?? "";
@@ -39,13 +51,13 @@ function urlSource(method: ActionMethod, def: ActionDef, context: LowerContext):
39
51
  * @param {ActionMethod} method
40
52
  * @param {ActionDef} def
41
53
  * @param {LowerContext} [context]
42
- * @returns {Record<string, unknown>} A core Function def with an `event` parameter
54
+ * @returns {JsonObject} A core Function def with an `event` parameter
43
55
  */
44
56
  export function lowerActionDef(
45
57
  method: ActionMethod,
46
58
  def: ActionDef,
47
59
  context: LowerContext = {},
48
- ): Record<string, unknown> {
60
+ ): JsonObject {
49
61
  const url = urlSource(method, def, context);
50
62
  const lines: string[] = [
51
63
  "if (event && typeof event.preventDefault === 'function') event.preventDefault();",
@@ -82,17 +94,14 @@ export function lowerActionDef(
82
94
  }
83
95
 
84
96
  /** 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> = {};
97
+ function evaluateValues(values: JsonObject | undefined, scope: ActionScope): JsonObject {
98
+ const out: JsonObject = {};
90
99
  for (const [key, value] of Object.entries(values ?? {})) {
91
- if (typeof value === "string" && value.includes("${")) {
100
+ if (isString(value) && value.includes("${")) {
92
101
  try {
93
- const fn = new Function("state", `return \`${value}\`;`) as (
94
- state: Record<string, unknown>,
95
- ) => unknown;
102
+ /* SAFETY: the body is a single `return` of a template literal, so the compiled function
103
+ takes the state scope as its one parameter and always returns a string. */
104
+ const fn = new Function("state", `return \`${value}\`;`) as ValueTemplate;
96
105
  out[key] = fn(scope);
97
106
  } catch {
98
107
  out[key] = value;
@@ -109,12 +118,12 @@ function evaluateValues(
109
118
  *
110
119
  * @param {ActionMethod} method
111
120
  * @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)
121
+ * @returns {(scope: ActionScope, event?: Event) => Promise<boolean>} Resolves true when the write
122
+ * succeeded (and `scope._v` was bumped)
114
123
  */
115
124
  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") {
125
+ return async (scope: ActionScope, event?: Event): Promise<boolean> => {
126
+ if (event && isFunction(event.preventDefault)) {
118
127
  event.preventDefault();
119
128
  }
120
129
  const table = config.table ?? "";
@@ -129,10 +138,15 @@ export function makeActionHandler(method: ActionMethod, config: ActionDef) {
129
138
  if (method === "DELETE") {
130
139
  init = { method };
131
140
  } else {
141
+ /* SAFETY: the handler is bound as an element's event listener (onsubmit/onclick), so the
142
+ event's target is the HTML element that dispatched it; `form` is read optionally because
143
+ only form-associated controls carry it. */
132
144
  const target = event?.target as
133
145
  | (HTMLElement & { form?: HTMLFormElement | null })
134
146
  | null
135
147
  | undefined;
148
+ /* SAFETY: Element.tagName reads "FORM" exactly for the HTML <form> element (HTML-namespace
149
+ tag names are reported uppercase), which is an HTMLFormElement. */
136
150
  form =
137
151
  target && target.tagName === "FORM" ? (target as HTMLFormElement) : (target?.form ?? null);
138
152
  const formData = form ? Object.fromEntries(new FormData(form)) : {};
@@ -146,10 +160,10 @@ export function makeActionHandler(method: ActionMethod, config: ActionDef) {
146
160
 
147
161
  const response = await fetch(url, init);
148
162
  if (response.ok) {
149
- if (method === "POST" && form && typeof form.reset === "function") {
163
+ if (method === "POST" && form && isFunction(form.reset)) {
150
164
  form.reset();
151
165
  }
152
- scope._v = ((scope._v as number) || 0) + 1;
166
+ scope._v = (scope._v || 0) + 1;
153
167
  }
154
168
  return response.ok;
155
169
  };
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
9
+ import type { JsonObject } from "@jxsuite/schema/types";
9
10
  import type { ActionDef } from "./table-actions.ts";
10
11
  import type { LowerContext } from "./table-state.ts";
11
12
 
@@ -22,7 +23,7 @@ export class TableDelete {
22
23
  }
23
24
 
24
25
  /** Static `lower` capability: compile away into an inline Function def. */
25
- static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
26
+ static lower(def: ActionDef, context: LowerContext = {}): JsonObject {
26
27
  return lowerActionDef("DELETE", def, context);
27
28
  }
28
29
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
10
+ import type { JsonObject } from "@jxsuite/schema/types";
10
11
  import type { ActionDef } from "./table-actions.ts";
11
12
  import type { LowerContext } from "./table-state.ts";
12
13
 
@@ -23,7 +24,7 @@ export class TableInsert {
23
24
  }
24
25
 
25
26
  /** Static `lower` capability: compile away into an inline Function def. */
26
- static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
27
+ static lower(def: ActionDef, context: LowerContext = {}): JsonObject {
27
28
  return lowerActionDef("POST", def, context);
28
29
  }
29
30
  }
package/src/table-node.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * empty results.
9
9
  */
10
10
 
11
+ import { isNumber } from "@jxsuite/schema/guards";
11
12
  import { Kysely } from "kysely";
12
13
  import { fromStorage, planTable } from "./columns.ts";
13
14
  import { resolveDialect } from "./connectors.ts";
@@ -15,20 +16,23 @@ import { syncTables } from "./ddl.ts";
15
16
  import { applyFilter, applySort, normalizeFilter, normalizeSort } from "./query.ts";
16
17
  import { tablesForConnection } from "./provider-utils.ts";
17
18
  import type { SqlDialectKind } from "./columns.ts";
19
+ import type { JsonValue } from "@jxsuite/schema/types";
18
20
  import type { ConnectionsConfig } from "./connectors.ts";
19
- import type { DynamicDatabase } from "./query.ts";
21
+ import type { DynamicDatabase, DynamicRow } from "./query.ts";
20
22
  import type { TableQueryDef } from "./table-shared.ts";
21
23
  import type { TableDef } from "./types.ts";
22
24
 
25
+ /** The project context hosts inject under `_project`: its config, root, and data section. */
26
+ export interface TableProjectContext {
27
+ config?: ConnectionsConfig & { data?: Record<string, TableDef> };
28
+ root?: string;
29
+ data?: Record<string, TableDef>;
30
+ }
31
+
23
32
  /** The `_project`-bearing config shape hosts inject into state classes. */
24
33
  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
- };
34
+ id?: JsonValue;
35
+ _project?: TableProjectContext;
32
36
  }
33
37
 
34
38
  interface OpenedTable {
@@ -48,10 +52,10 @@ async function openTable(config: TableNodeConfig): Promise<OpenedTable> {
48
52
  if (!tableDef) {
49
53
  throw new Error(`unknown table "${tableName}" (no data section entry)`);
50
54
  }
51
- const env: Record<string, unknown> = {
52
- ...process.env,
53
- ...(project.root === undefined ? {} : { JX_PROJECT_ROOT: project.root }),
54
- };
55
+ const env = { ...process.env };
56
+ if (project.root !== undefined) {
57
+ env["JX_PROJECT_ROOT"] = project.root;
58
+ }
55
59
  const { dialect, type } = await resolveDialect(tableDef.connection, projectConfig, env);
56
60
  const db = new Kysely<DynamicDatabase>({ dialect });
57
61
  const connection = projectConfig.connections?.[tableDef.connection];
@@ -66,19 +70,19 @@ async function openTable(config: TableNodeConfig): Promise<OpenedTable> {
66
70
  * Query a table with the def's filter/sort/limit/offset (the content grammar).
67
71
  *
68
72
  * @param {TableNodeConfig} config
69
- * @returns {Promise<Record<string, unknown>[]>}
73
+ * @returns {Promise<DynamicRow[]>}
70
74
  */
71
- export async function queryTable(config: TableNodeConfig): Promise<Record<string, unknown>[]> {
75
+ export async function queryTable(config: TableNodeConfig): Promise<DynamicRow[]> {
72
76
  const { db, kind, tables, tableName } = await openTable(config);
73
77
  try {
74
78
  const plan = planTable(tableName, tables[tableName]!, tables, kind);
75
79
  let qb = db.selectFrom(tableName).selectAll();
76
80
  qb = applyFilter(qb, normalizeFilter(config.filter), plan.columns);
77
81
  qb = applySort(qb, normalizeSort(config.sort), plan.columns);
78
- if (typeof config.limit === "number" && config.limit > 0) {
82
+ if (isNumber(config.limit) && config.limit > 0) {
79
83
  qb = qb.limit(config.limit);
80
84
  }
81
- if (typeof config.offset === "number" && config.offset > 0) {
85
+ if (isNumber(config.offset) && config.offset > 0) {
82
86
  qb = qb.limit(config.limit ?? 100_000_000).offset(config.offset);
83
87
  }
84
88
  const rows = await qb.execute();
@@ -93,12 +97,9 @@ export async function queryTable(config: TableNodeConfig): Promise<Record<string
93
97
  *
94
98
  * @param {TableNodeConfig} config
95
99
  * @param {string} id
96
- * @returns {Promise<Record<string, unknown> | null>}
100
+ * @returns {Promise<DynamicRow | null>}
97
101
  */
98
- export async function getEntry(
99
- config: TableNodeConfig,
100
- id: string,
101
- ): Promise<Record<string, unknown> | null> {
102
+ export async function getEntry(config: TableNodeConfig, id: string): Promise<DynamicRow | null> {
102
103
  const { db, kind, tables, tableName } = await openTable(config);
103
104
  try {
104
105
  const plan = planTable(tableName, tables[tableName]!, tables, kind);
@@ -12,6 +12,9 @@
12
12
  * machinery.
13
13
  */
14
14
 
15
+ import { isJsonObject, isNumber, isRef, isString } from "@jxsuite/schema/guards";
16
+ import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
17
+
15
18
  /** A `${...}` template span (Jx binding syntax preserved through lowering). */
16
19
  const TEMPLATE_SPAN = /(\$\{[^}]*\})/;
17
20
 
@@ -32,12 +35,13 @@ export function encodeQueryValue(value: string): string {
32
35
  /** The wire-facing query options a TableQuery/TableEntry def may carry. */
33
36
  export interface TableQueryDef {
34
37
  table?: string;
35
- filter?: unknown;
36
- sort?: unknown;
38
+ /** The content filter grammar: a rule array or an equality-object shorthand. */
39
+ filter?: JsonValue;
40
+ /** One `{ field, order }` sort rule or a list of them. */
41
+ sort?: JsonValue;
37
42
  limit?: number;
38
43
  offset?: number;
39
44
  include?: string | string[];
40
- [key: string]: unknown;
41
45
  }
42
46
 
43
47
  /**
@@ -60,10 +64,10 @@ export function buildDataUrl(
60
64
  if (def.sort !== undefined && def.sort !== null) {
61
65
  parts.push(`sort=${encodeQueryValue(JSON.stringify(def.sort))}`);
62
66
  }
63
- if (typeof def.limit === "number") {
67
+ if (isNumber(def.limit)) {
64
68
  parts.push(`limit=${def.limit}`);
65
69
  }
66
- if (typeof def.offset === "number") {
70
+ if (isNumber(def.offset)) {
67
71
  parts.push(`offset=${def.offset}`);
68
72
  }
69
73
  const include = Array.isArray(def.include) ? def.include.join(",") : def.include;
@@ -82,23 +86,22 @@ export function buildDataUrl(
82
86
  * Resolve an id value: `#/$params/<name>` refs read route params, everything else passes through as
83
87
  * a string (template placeholders included).
84
88
  *
85
- * @param {unknown} id - The def's id value
89
+ * @param {JsonValue | undefined} id - The def's id value
86
90
  * @param {Record<string, string> | undefined} pathParams - Route params when known
87
91
  * @returns {string | undefined}
88
92
  */
89
93
  export function resolveIdValue(
90
- id: unknown,
94
+ id: JsonValue | undefined,
91
95
  pathParams: Record<string, string> | undefined,
92
96
  ): string | undefined {
93
97
  if (id === null || id === undefined) {
94
98
  return undefined;
95
99
  }
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
- }
100
+ if (isRef(id) && id.$ref.startsWith("#/$params/")) {
101
+ const param = id.$ref.slice("#/$params/".length);
102
+ return pathParams?.[param];
103
+ }
104
+ if (Array.isArray(id) || isJsonObject(id)) {
102
105
  return undefined;
103
106
  }
104
107
  return String(id);
@@ -108,19 +111,20 @@ export function resolveIdValue(
108
111
  * Emit the JS source for a literal value inside a lowered Function body. Strings carrying `${...}`
109
112
  * become template literals (evaluated against state at runtime); everything else is inlined JSON.
110
113
  *
111
- * @param {unknown} value
114
+ * @param {JsonValue | undefined} value
112
115
  * @returns {string}
113
116
  */
114
- export function jsValueSource(value: unknown): string {
115
- if (typeof value === "string" && value.includes("${")) {
117
+ export function jsValueSource(value: JsonValue | undefined): string {
118
+ if (isString(value) && value.includes("${")) {
116
119
  const escaped = value.replaceAll("\\", String.raw`\\`).replaceAll("`", "\\`");
117
120
  return `\`${escaped}\``;
118
121
  }
119
- return JSON.stringify(value) ?? "null";
122
+ // JSON.stringify(undefined) yields undefined rather than a string; an absent value inlines null.
123
+ return value === undefined ? "null" : JSON.stringify(value);
120
124
  }
121
125
 
122
126
  /** Emit the JS source of an object literal whose values go through {@link jsValueSource}. */
123
- export function jsObjectSource(values: Record<string, unknown> | undefined): string {
127
+ export function jsObjectSource(values: JsonObject | undefined): string {
124
128
  if (!values || Object.keys(values).length === 0) {
125
129
  return "{}";
126
130
  }
@@ -14,42 +14,55 @@
14
14
  * cache-busting param — see table-shared.ts.
15
15
  */
16
16
 
17
+ import { isFunction } from "@jxsuite/schema/guards";
17
18
  import { buildDataUrl, resolveIdValue } from "./table-shared.ts";
19
+ import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
20
+ import type { DynamicRow } from "./query.ts";
18
21
  import type { TableQueryDef } from "./table-shared.ts";
22
+ import type { TableNodeConfig, getEntry, queryTable } from "./table-node.ts";
19
23
 
20
24
  /** Route/document context injected by hosts under `_document`. */
21
25
  interface DocumentContext {
22
26
  route?: { _pathParams?: Record<string, string> };
23
- [key: string]: unknown;
24
27
  }
25
28
 
26
29
  /** The context object hosts pass to `lower(def, context)`. */
27
30
  export interface LowerContext {
28
31
  route?: { _pathParams?: Record<string, string>; sourcePath?: string };
29
- projectConfig?: Record<string, unknown>;
32
+ projectConfig?: JsonObject;
30
33
  root?: string;
31
34
  }
32
35
 
33
- interface TableStateConfig extends TableQueryDef {
34
- id?: unknown;
36
+ interface TableStateConfig extends TableNodeConfig {
35
37
  _document?: DocumentContext;
36
- _project?: Record<string, unknown>;
38
+ }
39
+
40
+ /**
41
+ * A TableQuery/TableEntry def as authored in a document, which is pure JSON: the query options, the
42
+ * `id` and `default` keys `lower` reads, and the class keys it carries past ($prototype, …).
43
+ */
44
+ interface TableLowerDef extends TableQueryDef {
45
+ id?: JsonValue;
46
+ default?: JsonValue;
47
+ [key: string]: JsonValue | undefined;
37
48
  }
38
49
 
39
50
  /** True when running in a browser (canvas/client); node hosts take the direct-database path. */
40
51
  function inBrowser(): boolean {
41
- return typeof document !== "undefined" && typeof fetch === "function";
52
+ return typeof document !== "undefined" && isFunction(globalThis.fetch);
42
53
  }
43
54
 
44
55
  /** The surface of the node-only sibling module (see table-node.ts). */
45
56
  interface TableNodeModule {
46
- queryTable: (config: TableStateConfig) => Promise<unknown>;
47
- getEntry: (config: TableStateConfig, id: string) => Promise<unknown>;
57
+ queryTable: typeof queryTable;
58
+ getEntry: typeof getEntry;
48
59
  }
49
60
 
50
61
  /** Import the node-only sibling through an opaque specifier (bundlers must not follow it). */
51
62
  async function nodeModule(): Promise<TableNodeModule> {
52
63
  const specifier = "./table-node.ts";
64
+ /* SAFETY: the opaque specifier is the literal "./table-node.ts" sibling, whose exports include
65
+ these two functions with exactly the types imported above. */
53
66
  return (await import(specifier)) as TableNodeModule;
54
67
  }
55
68
 
@@ -61,10 +74,12 @@ export class TableQuery {
61
74
  }
62
75
 
63
76
  /** Rows matching the def's filter/sort/limit — over HTTP in browsers, directly under node. */
64
- async resolve(): Promise<unknown> {
77
+ async resolve(): Promise<DynamicRow[]> {
65
78
  if (inBrowser()) {
66
79
  const response = await fetch(buildDataUrl(this.config));
67
- return response.ok ? await response.json() : [];
80
+ /* SAFETY: a 2xx from GET /_jx/data/:table is the data mount's list answer, Response.json()
81
+ of the row array (worker.ts listRows), so the parsed body is an array of row objects. */
82
+ return response.ok ? ((await response.json()) as DynamicRow[]) : [];
68
83
  }
69
84
  try {
70
85
  const { queryTable } = await nodeModule();
@@ -77,11 +92,11 @@ export class TableQuery {
77
92
  }
78
93
 
79
94
  /** Static `lower` capability: compile away into a core Request def (client fetch). */
80
- static lower(def: Record<string, unknown>): Record<string, unknown> {
95
+ static lower(def: TableLowerDef): JsonObject {
81
96
  return {
82
97
  $prototype: "Request",
83
98
  timing: "client",
84
- url: buildDataUrl(def as TableQueryDef, { versionParam: true }),
99
+ url: buildDataUrl(def, { versionParam: true }),
85
100
  ...(def.default === undefined ? { default: [] } : { default: def.default }),
86
101
  };
87
102
  }
@@ -95,7 +110,7 @@ export class TableEntry {
95
110
  }
96
111
 
97
112
  /** One row by id (`#/$params/` refs resolve against the route), or null. */
98
- async resolve(): Promise<unknown> {
113
+ async resolve(): Promise<DynamicRow | null> {
99
114
  const { _document } = this.config;
100
115
  const id = resolveIdValue(this.config.id, _document?.route?._pathParams);
101
116
  if (id === undefined || id === "") {
@@ -103,7 +118,9 @@ export class TableEntry {
103
118
  }
104
119
  if (inBrowser()) {
105
120
  const response = await fetch(buildDataUrl(this.config, { id }));
106
- return response.ok ? await response.json() : null;
121
+ /* SAFETY: a 2xx from GET /_jx/data/:table/:id is the data mount's single-row answer,
122
+ Response.json() of one row object (worker.ts getRow). */
123
+ return response.ok ? ((await response.json()) as DynamicRow) : null;
107
124
  }
108
125
  try {
109
126
  const { getEntry } = await nodeModule();
@@ -116,7 +133,7 @@ export class TableEntry {
116
133
  }
117
134
 
118
135
  /** 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> {
136
+ static lower(def: TableLowerDef, context: LowerContext = {}): JsonObject {
120
137
  const id = resolveIdValue(def.id, context.route?._pathParams);
121
138
  if (id === undefined) {
122
139
  console.warn(
@@ -127,7 +144,7 @@ export class TableEntry {
127
144
  return {
128
145
  $prototype: "Request",
129
146
  timing: "client",
130
- url: buildDataUrl(def as TableQueryDef, { id: id ?? "", versionParam: true }),
147
+ url: buildDataUrl(def, { id: id ?? "", versionParam: true }),
131
148
  ...(def.default === undefined ? { default: null } : { default: def.default }),
132
149
  };
133
150
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { lowerActionDef, makeActionHandler } from "./table-actions.ts";
10
+ import type { JsonObject } from "@jxsuite/schema/types";
10
11
  import type { ActionDef } from "./table-actions.ts";
11
12
  import type { LowerContext } from "./table-state.ts";
12
13
 
@@ -23,7 +24,7 @@ export class TableUpdate {
23
24
  }
24
25
 
25
26
  /** Static `lower` capability: compile away into an inline Function def. */
26
- static lower(def: ActionDef, context: LowerContext = {}): Record<string, unknown> {
27
+ static lower(def: ActionDef, context: LowerContext = {}): JsonObject {
27
28
  return lowerActionDef("PATCH", def, context);
28
29
  }
29
30
  }