@frockbot/applet-sdk 0.0.0 → 0.3.13

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.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Schema-first table and column declarations.
3
+ *
4
+ * One declaration produces four things that must never drift apart: the SQLite
5
+ * DDL the server creates, the row validator every write goes through, the JSON
6
+ * Schema a tool declaration publishes, and the TypeScript row/insert types the
7
+ * author and the client see. Everything else in the SDK reads this module
8
+ * rather than re-deriving any of them.
9
+ */
10
+
11
+ export type ColumnKind =
12
+ "id" | "text" | "integer" | "real" | "boolean" | "timestamp" | "json";
13
+
14
+ export interface ColumnDefinition {
15
+ readonly kind: ColumnKind;
16
+ readonly optional: boolean;
17
+ readonly hasDefault: boolean;
18
+ readonly defaultValue?: unknown;
19
+ }
20
+
21
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,62}$/;
22
+ /** Table names the SDK owns; an Applet may not declare them. */
23
+ export const RESERVED_TABLE_PREFIX = "_applet_";
24
+
25
+ /** Value surfaced to TypeScript for a column of each kind. */
26
+ export interface ColumnValueByKind {
27
+ id: string;
28
+ text: string;
29
+ integer: number;
30
+ real: number;
31
+ boolean: boolean;
32
+ /** ISO-8601 UTC instant, stored as TEXT so it sorts lexicographically. */
33
+ timestamp: string;
34
+ json: unknown;
35
+ }
36
+
37
+ export class Column<
38
+ TValue = unknown,
39
+ TOptional extends boolean = false,
40
+ THasDefault extends boolean = false,
41
+ > {
42
+ /** Nominal marker so `ColumnValue<C>` can never resolve structurally. */
43
+ declare readonly __value: TValue;
44
+ declare readonly __optional: TOptional;
45
+ declare readonly __hasDefault: THasDefault;
46
+
47
+ constructor(readonly definition: ColumnDefinition) {}
48
+
49
+ /**
50
+ * A value written when the insert omits the column. Also makes the column
51
+ * optional in the insert type.
52
+ */
53
+ default(value: TValue): Column<TValue, TOptional, true> {
54
+ return new Column({
55
+ ...this.definition,
56
+ hasDefault: true,
57
+ defaultValue: value,
58
+ });
59
+ }
60
+
61
+ /** Nullable in SQLite, `T | null` in TypeScript, omittable on insert. */
62
+ optional(): Column<TValue | null, true, THasDefault> {
63
+ return new Column({ ...this.definition, optional: true });
64
+ }
65
+ }
66
+
67
+ export type AnyColumn = Column<any, boolean, boolean>;
68
+
69
+ function column<K extends ColumnKind>(
70
+ kind: K,
71
+ ): Column<ColumnValueByKind[K], false, K extends "id" ? true : false> {
72
+ return new Column({
73
+ kind,
74
+ optional: false,
75
+ // An id is generated by the server when the insert omits it.
76
+ hasDefault: kind === "id",
77
+ }) as never;
78
+ }
79
+
80
+ /** Column constructors. `t.id()` is the primary key and is server-generated. */
81
+ export const t = {
82
+ id: () => column("id"),
83
+ text: () => column("text"),
84
+ integer: () => column("integer"),
85
+ real: () => column("real"),
86
+ boolean: () => column("boolean"),
87
+ timestamp: () => column("timestamp"),
88
+ json: () => column("json"),
89
+ } as const;
90
+
91
+ export type ColumnsShape = Record<string, AnyColumn>;
92
+
93
+ export class TableDefinition<TColumns extends ColumnsShape = ColumnsShape> {
94
+ readonly primaryKey: string;
95
+
96
+ constructor(readonly columns: TColumns) {
97
+ const names = Object.keys(columns);
98
+ if (names.length === 0 || names.length > 64) {
99
+ throw new Error("A table must declare between 1 and 64 columns");
100
+ }
101
+ const keys: string[] = [];
102
+ for (const name of names) {
103
+ if (!IDENTIFIER.test(name)) {
104
+ throw new Error(`Column name "${name}" is not a valid identifier`);
105
+ }
106
+ if (columns[name]!.definition.kind === "id") keys.push(name);
107
+ }
108
+ if (keys.length !== 1) {
109
+ throw new Error("A table must declare exactly one t.id() column");
110
+ }
111
+ if (columns[keys[0]!]!.definition.optional) {
112
+ throw new Error("The t.id() column may not be optional");
113
+ }
114
+ this.primaryKey = keys[0]!;
115
+ }
116
+ }
117
+
118
+ /** Declare a table. Column order is the DDL order and the snapshot order. */
119
+ export function table<TColumns extends ColumnsShape>(
120
+ columns: TColumns,
121
+ ): TableDefinition<TColumns> {
122
+ return new TableDefinition(columns);
123
+ }
124
+
125
+ export type TablesShape = Record<string, TableDefinition<ColumnsShape>>;
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Type inference
129
+ // ---------------------------------------------------------------------------
130
+
131
+ type ColumnValue<C> = C extends Column<infer V, boolean, boolean> ? V : never;
132
+ type OptionalOnInsert<C> =
133
+ C extends Column<unknown, infer O, infer D>
134
+ ? O extends true
135
+ ? true
136
+ : D extends true
137
+ ? true
138
+ : false
139
+ : false;
140
+
141
+ type InsertOptionalKeys<TColumns extends ColumnsShape> = {
142
+ [K in keyof TColumns]: OptionalOnInsert<TColumns[K]> extends true ? K : never;
143
+ }[keyof TColumns];
144
+
145
+ /** The shape a row has once it is stored: every column present. */
146
+ export type RowOf<T> =
147
+ T extends TableDefinition<infer TColumns>
148
+ ? { [K in keyof TColumns]: ColumnValue<TColumns[K]> }
149
+ : never;
150
+
151
+ /** The shape an insert accepts: defaulted and optional columns may be omitted. */
152
+ export type InsertOf<T> =
153
+ T extends TableDefinition<infer TColumns>
154
+ ? {
155
+ [
156
+ K in Exclude<keyof TColumns, InsertOptionalKeys<TColumns>>
157
+ ]: ColumnValue<TColumns[K]>;
158
+ } & {
159
+ [K in InsertOptionalKeys<TColumns> & keyof TColumns]?: ColumnValue<
160
+ TColumns[K]
161
+ >;
162
+ }
163
+ : never;
164
+
165
+ /** The shape an update accepts: any column but the primary key. */
166
+ export type PatchOf<T> =
167
+ T extends TableDefinition<infer TColumns>
168
+ ? Partial<{ [K in keyof TColumns]: ColumnValue<TColumns[K]> }>
169
+ : never;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // DDL
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const SQLITE_TYPE: Record<ColumnKind, string> = {
176
+ id: "TEXT",
177
+ text: "TEXT",
178
+ integer: "INTEGER",
179
+ real: "REAL",
180
+ boolean: "INTEGER",
181
+ timestamp: "TEXT",
182
+ json: "TEXT",
183
+ };
184
+
185
+ /** Also allows the SDK's own leading-underscore tables. */
186
+ const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
187
+
188
+ export function quoteIdentifier(name: string): string {
189
+ if (!SQL_IDENTIFIER.test(name)) {
190
+ throw new Error(`"${name}" is not a valid SQL identifier`);
191
+ }
192
+ return `"${name}"`;
193
+ }
194
+
195
+ function columnDdl(name: string, definition: ColumnDefinition): string {
196
+ const type = SQLITE_TYPE[definition.kind];
197
+ if (definition.kind === "id") {
198
+ return `${quoteIdentifier(name)} ${type} PRIMARY KEY NOT NULL`;
199
+ }
200
+ return `${quoteIdentifier(name)} ${type}${definition.optional ? "" : " NOT NULL"}`;
201
+ }
202
+
203
+ /** `CREATE TABLE IF NOT EXISTS`; safe to run on every mount. */
204
+ export function createTableStatement(
205
+ name: string,
206
+ definition: TableDefinition,
207
+ ): string {
208
+ const columns = Object.entries(definition.columns).map(([column, spec]) =>
209
+ columnDdl(column, spec.definition),
210
+ );
211
+ return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(name)} (${columns.join(", ")})`;
212
+ }
213
+
214
+ /**
215
+ * `ALTER TABLE ... ADD COLUMN` for a column added since the table was created.
216
+ * SQLite refuses a NOT NULL add without a default, so an added required column
217
+ * is written with the column's default and rejected when it has none — which
218
+ * is exactly the case an Applet must handle in `migrate`.
219
+ */
220
+ export function addColumnStatement(
221
+ table: string,
222
+ name: string,
223
+ column: AnyColumn,
224
+ ): string {
225
+ const definition = column.definition;
226
+ if (definition.kind === "id") {
227
+ throw new Error("The primary key cannot be added to an existing table");
228
+ }
229
+ const type = SQLITE_TYPE[definition.kind];
230
+ if (definition.optional) {
231
+ return `ALTER TABLE ${quoteIdentifier(table)} ADD COLUMN ${quoteIdentifier(name)} ${type}`;
232
+ }
233
+ if (!definition.hasDefault) {
234
+ throw new Error(
235
+ `Column "${table}.${name}" was added without .default() or .optional(); ` +
236
+ "existing rows have no value for it",
237
+ );
238
+ }
239
+ const literal = sqlLiteral(
240
+ encodeValue(name, definition, definition.defaultValue),
241
+ );
242
+ return `ALTER TABLE ${quoteIdentifier(table)} ADD COLUMN ${quoteIdentifier(name)} ${type} NOT NULL DEFAULT ${literal}`;
243
+ }
244
+
245
+ function sqlLiteral(value: SqlValue): string {
246
+ if (value === null) return "NULL";
247
+ if (typeof value === "number") return String(value);
248
+ return `'${value.replaceAll("'", "''")}'`;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Value coding and validation
253
+ // ---------------------------------------------------------------------------
254
+
255
+ export type SqlValue = string | number | null;
256
+
257
+ export class AppletValidationError extends Error {}
258
+
259
+ function fail(message: string): never {
260
+ throw new AppletValidationError(message);
261
+ }
262
+
263
+ const ISO =
264
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
265
+
266
+ /** Validate an author- or client-supplied value and encode it for SQLite. */
267
+ export function encodeValue(
268
+ name: string,
269
+ definition: ColumnDefinition,
270
+ value: unknown,
271
+ ): SqlValue {
272
+ if (value === null || value === undefined) {
273
+ if (!definition.optional) fail(`Column "${name}" may not be null`);
274
+ return null;
275
+ }
276
+ switch (definition.kind) {
277
+ case "id":
278
+ case "text":
279
+ if (typeof value !== "string" || value.length > 16_384) {
280
+ fail(`Column "${name}" must be a string of at most 16384 characters`);
281
+ }
282
+ return value;
283
+ case "integer":
284
+ if (!Number.isSafeInteger(value)) {
285
+ fail(`Column "${name}" must be a safe integer`);
286
+ }
287
+ return value as number;
288
+ case "real":
289
+ if (typeof value !== "number" || !Number.isFinite(value)) {
290
+ fail(`Column "${name}" must be a finite number`);
291
+ }
292
+ return value;
293
+ case "boolean":
294
+ if (typeof value !== "boolean")
295
+ fail(`Column "${name}" must be a boolean`);
296
+ return value ? 1 : 0;
297
+ case "timestamp":
298
+ if (typeof value !== "string" || !ISO.test(value)) {
299
+ fail(`Column "${name}" must be an ISO-8601 instant`);
300
+ }
301
+ return value;
302
+ case "json": {
303
+ let wire: string;
304
+ try {
305
+ wire = JSON.stringify(value);
306
+ } catch {
307
+ return fail(`Column "${name}" must contain acyclic JSON`);
308
+ }
309
+ if (wire === undefined) fail(`Column "${name}" must contain JSON`);
310
+ if (wire.length > 65_536) fail(`Column "${name}" JSON is too large`);
311
+ return wire;
312
+ }
313
+ }
314
+ }
315
+
316
+ /** Decode a SQLite cell back to the value the author and client see. */
317
+ export function decodeValue(
318
+ definition: ColumnDefinition,
319
+ value: unknown,
320
+ ): unknown {
321
+ if (value === null || value === undefined) return null;
322
+ switch (definition.kind) {
323
+ case "boolean":
324
+ return value !== 0;
325
+ case "json":
326
+ return JSON.parse(String(value));
327
+ case "integer":
328
+ case "real":
329
+ return Number(value);
330
+ default:
331
+ return String(value);
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // JSON Schema (tool inputs)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ export interface JsonSchemaObject {
340
+ type: "object";
341
+ properties: Record<string, Record<string, unknown>>;
342
+ required: string[];
343
+ additionalProperties: false;
344
+ }
345
+
346
+ const JSON_SCHEMA_BY_KIND: Record<ColumnKind, Record<string, unknown>> = {
347
+ id: { type: "string" },
348
+ text: { type: "string" },
349
+ integer: { type: "integer" },
350
+ real: { type: "number" },
351
+ boolean: { type: "boolean" },
352
+ timestamp: { type: "string", format: "date-time" },
353
+ json: {},
354
+ };
355
+
356
+ /** Turn a record of columns into the JSON Schema a tool declaration carries. */
357
+ export function jsonSchemaFromColumns(columns: ColumnsShape): JsonSchemaObject {
358
+ const properties: Record<string, Record<string, unknown>> = {};
359
+ const required: string[] = [];
360
+ for (const [name, spec] of Object.entries(columns)) {
361
+ const definition = spec.definition;
362
+ const base = { ...JSON_SCHEMA_BY_KIND[definition.kind] };
363
+ properties[name] = definition.optional
364
+ ? { anyOf: [base, { type: "null" }] }
365
+ : base;
366
+ if (!definition.optional && !definition.hasDefault) required.push(name);
367
+ }
368
+ return { type: "object", properties, required, additionalProperties: false };
369
+ }
370
+
371
+ /** Validate a tool input against its declared columns and decode it. */
372
+ export function decodeToolInput(
373
+ columns: ColumnsShape,
374
+ input: unknown,
375
+ ): Record<string, unknown> {
376
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
377
+ fail("Tool input must be an object");
378
+ }
379
+ const value = input as Record<string, unknown>;
380
+ for (const key of Object.keys(value)) {
381
+ if (!Object.hasOwn(columns, key)) fail(`Unknown tool input field "${key}"`);
382
+ }
383
+ const out: Record<string, unknown> = {};
384
+ for (const [name, spec] of Object.entries(columns)) {
385
+ const definition = spec.definition;
386
+ const supplied = value[name];
387
+ if (supplied === undefined) {
388
+ if (definition.hasDefault) {
389
+ out[name] = definition.defaultValue;
390
+ continue;
391
+ }
392
+ if (definition.optional) {
393
+ out[name] = null;
394
+ continue;
395
+ }
396
+ fail(`Tool input field "${name}" is required`);
397
+ }
398
+ // Encode to validate, decode to hand the author the ordinary value.
399
+ out[name] = decodeValue(
400
+ definition,
401
+ encodeValue(name, definition, supplied),
402
+ );
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * A stable fingerprint of the declared schema. The server stores it so a mount
409
+ * over existing storage can tell "unchanged" from "needs migrate" without the
410
+ * author maintaining a revision number by hand.
411
+ */
412
+ export function schemaFingerprint(tables: TablesShape): string {
413
+ const shape = Object.keys(tables)
414
+ .sort()
415
+ .map((name) => {
416
+ const definition = tables[name]!;
417
+ const columns = Object.entries(definition.columns).map(
418
+ ([column, spec]) =>
419
+ `${column}:${spec.definition.kind}${spec.definition.optional ? "?" : ""}`,
420
+ );
421
+ return `${name}(${columns.join(",")})`;
422
+ });
423
+ return shape.join(";");
424
+ }
425
+
426
+ export function assertTableNames(tables: TablesShape): void {
427
+ const names = Object.keys(tables);
428
+ if (names.length === 0 || names.length > 32) {
429
+ throw new Error("An Applet must declare between 1 and 32 tables");
430
+ }
431
+ for (const name of names) {
432
+ if (!IDENTIFIER.test(name) || name.startsWith(RESERVED_TABLE_PREFIX)) {
433
+ throw new Error(`Table name "${name}" is invalid or reserved`);
434
+ }
435
+ }
436
+ }