@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.
- package/README.md +77 -1
- package/dist/cli.mjs +1007 -0
- package/package.json +51 -5
- package/src/cli/bin.ts +128 -0
- package/src/cli/build.ts +181 -0
- package/src/cli/check.ts +134 -0
- package/src/cli/dev.ts +84 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/manifest.ts +58 -0
- package/src/cli/new.ts +74 -0
- package/src/cli/paths.ts +98 -0
- package/src/cli/runtime.ts +123 -0
- package/src/client/collections.ts +72 -0
- package/src/client/index.ts +203 -0
- package/src/client/transport.ts +334 -0
- package/src/kit/README.md +130 -0
- package/src/kit/index.tsx +427 -0
- package/src/kit/styles.ts +200 -0
- package/src/lint/index.ts +148 -0
- package/src/lint/rules.ts +394 -0
- package/src/protocol/index.ts +411 -0
- package/src/schema/index.ts +436 -0
- package/src/server/applet.ts +399 -0
- package/src/server/index.ts +53 -0
- package/src/server/session.ts +156 -0
- package/src/server/store.ts +398 -0
- package/template/README.md +37 -0
- package/template/applet.json +5 -0
- package/template/server.ts +46 -0
- package/template/ui.tsx +113 -0
- package/types/cloudflare-workers.d.ts +55 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite side of an Applet: DDL, typed rows, and the change log.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is synchronous against a `SqlStorage`-shaped handle, so the
|
|
5
|
+
* caller can wrap a whole client transaction in `ctx.storage.transactionSync`
|
|
6
|
+
* and so the whole module is testable against any SQLite driver.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
AppletValidationError,
|
|
11
|
+
addColumnStatement,
|
|
12
|
+
createTableStatement,
|
|
13
|
+
decodeValue,
|
|
14
|
+
encodeValue,
|
|
15
|
+
quoteIdentifier,
|
|
16
|
+
schemaFingerprint,
|
|
17
|
+
type SqlValue,
|
|
18
|
+
type TableDefinition,
|
|
19
|
+
type TablesShape,
|
|
20
|
+
} from "../schema/index.js";
|
|
21
|
+
import type { AppletChangeV1, AppletMutationV1 } from "../protocol/index.js";
|
|
22
|
+
|
|
23
|
+
/** The subset of Cloudflare's `SqlStorage` the SDK uses. */
|
|
24
|
+
export interface AppletSqlStorage {
|
|
25
|
+
exec(
|
|
26
|
+
query: string,
|
|
27
|
+
...bindings: unknown[]
|
|
28
|
+
): { toArray(): Array<Record<string, unknown>> };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const META_TABLE = "_applet_meta";
|
|
32
|
+
const CHANGE_TABLE = "_applet_changes";
|
|
33
|
+
/** Retained change-log length; a client further behind gets a full snapshot. */
|
|
34
|
+
export const CHANGE_LOG_LIMIT = 2_000;
|
|
35
|
+
|
|
36
|
+
export interface SchemaState {
|
|
37
|
+
revision: number;
|
|
38
|
+
/** True when this mount changed the declared shape. */
|
|
39
|
+
changed: boolean;
|
|
40
|
+
previousRevision: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AppletStore {
|
|
44
|
+
private readonly sql: AppletSqlStorage;
|
|
45
|
+
readonly tables: TablesShape;
|
|
46
|
+
private cursor = 0;
|
|
47
|
+
|
|
48
|
+
constructor(sql: AppletSqlStorage, tables: TablesShape) {
|
|
49
|
+
this.sql = sql;
|
|
50
|
+
this.tables = tables;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Idempotent DDL. Creates the SDK's own tables and every declared table,
|
|
55
|
+
* adds columns declared since the last mount, and reports whether the shape
|
|
56
|
+
* moved so the caller can run `migrate`.
|
|
57
|
+
*/
|
|
58
|
+
ensureSchema(): SchemaState {
|
|
59
|
+
this.sql.exec(
|
|
60
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(META_TABLE)} ("key" TEXT PRIMARY KEY NOT NULL, "value" TEXT NOT NULL)`,
|
|
61
|
+
);
|
|
62
|
+
this.sql.exec(
|
|
63
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(CHANGE_TABLE)} (` +
|
|
64
|
+
`"seq" INTEGER PRIMARY KEY AUTOINCREMENT, "txnId" TEXT, "tbl" TEXT NOT NULL, ` +
|
|
65
|
+
`"op" TEXT NOT NULL, "rowKey" TEXT NOT NULL, "row" TEXT, "at" TEXT NOT NULL)`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
for (const [name, definition] of Object.entries(this.tables)) {
|
|
69
|
+
this.sql.exec(createTableStatement(name, definition));
|
|
70
|
+
const existing = new Set(
|
|
71
|
+
this.sql
|
|
72
|
+
.exec(`PRAGMA table_info(${quoteIdentifier(name)})`)
|
|
73
|
+
.toArray()
|
|
74
|
+
.map((column) => String(column.name)),
|
|
75
|
+
);
|
|
76
|
+
for (const [column, spec] of Object.entries(definition.columns)) {
|
|
77
|
+
if (existing.has(column)) continue;
|
|
78
|
+
this.sql.exec(addColumnStatement(name, column, spec));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.cursor = Number(
|
|
83
|
+
this.sql
|
|
84
|
+
.exec(`SELECT MAX("seq") AS seq FROM ${quoteIdentifier(CHANGE_TABLE)}`)
|
|
85
|
+
.toArray()[0]?.seq ?? 0,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const fingerprint = schemaFingerprint(this.tables);
|
|
89
|
+
const storedFingerprint = this.readMeta("schema:fingerprint");
|
|
90
|
+
const previousRevision = Number(this.readMeta("schema:revision") ?? 0);
|
|
91
|
+
if (storedFingerprint === fingerprint) {
|
|
92
|
+
return { revision: previousRevision, changed: false, previousRevision };
|
|
93
|
+
}
|
|
94
|
+
const revision = previousRevision + 1;
|
|
95
|
+
this.writeMeta("schema:fingerprint", fingerprint);
|
|
96
|
+
this.writeMeta("schema:revision", String(revision));
|
|
97
|
+
return { revision, changed: true, previousRevision };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private readMeta(key: string): string | undefined {
|
|
101
|
+
const rows = this.sql
|
|
102
|
+
.exec(
|
|
103
|
+
`SELECT "value" FROM ${quoteIdentifier(META_TABLE)} WHERE "key" = ?`,
|
|
104
|
+
key,
|
|
105
|
+
)
|
|
106
|
+
.toArray();
|
|
107
|
+
return rows.length === 0 ? undefined : String(rows[0]!.value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private writeMeta(key: string, value: string): void {
|
|
111
|
+
this.sql.exec(
|
|
112
|
+
`INSERT INTO ${quoteIdentifier(META_TABLE)} ("key", "value") VALUES (?, ?) ` +
|
|
113
|
+
`ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"`,
|
|
114
|
+
key,
|
|
115
|
+
value,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get lastChangeId(): number {
|
|
120
|
+
return this.cursor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fail closed on a table the Applet did not declare. */
|
|
124
|
+
private definition(table: string): TableDefinition {
|
|
125
|
+
const definition = this.tables[table];
|
|
126
|
+
if (!definition) {
|
|
127
|
+
throw new AppletValidationError(`Unknown table "${table}"`);
|
|
128
|
+
}
|
|
129
|
+
return definition;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private decodeRow(
|
|
133
|
+
definition: TableDefinition,
|
|
134
|
+
raw: Record<string, unknown>,
|
|
135
|
+
): Record<string, unknown> {
|
|
136
|
+
const row: Record<string, unknown> = {};
|
|
137
|
+
for (const [column, spec] of Object.entries(definition.columns)) {
|
|
138
|
+
row[column] = decodeValue(spec.definition, raw[column]);
|
|
139
|
+
}
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private encodeInsert(
|
|
144
|
+
table: string,
|
|
145
|
+
definition: TableDefinition,
|
|
146
|
+
values: Record<string, unknown>,
|
|
147
|
+
suppliedKey?: string,
|
|
148
|
+
): { key: string; columns: string[]; bindings: SqlValue[] } {
|
|
149
|
+
for (const column of Object.keys(values)) {
|
|
150
|
+
if (!Object.hasOwn(definition.columns, column)) {
|
|
151
|
+
throw new AppletValidationError(`Unknown column "${table}.${column}"`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const columns: string[] = [];
|
|
155
|
+
const bindings: SqlValue[] = [];
|
|
156
|
+
let key = suppliedKey;
|
|
157
|
+
for (const [column, spec] of Object.entries(definition.columns)) {
|
|
158
|
+
const meta = spec.definition;
|
|
159
|
+
let value = values[column];
|
|
160
|
+
if (column === definition.primaryKey) {
|
|
161
|
+
value =
|
|
162
|
+
(value as string | undefined) ?? suppliedKey ?? crypto.randomUUID();
|
|
163
|
+
key = value as string;
|
|
164
|
+
} else if (value === undefined) {
|
|
165
|
+
if (meta.hasDefault) value = meta.defaultValue;
|
|
166
|
+
else if (meta.optional) value = null;
|
|
167
|
+
else {
|
|
168
|
+
throw new AppletValidationError(
|
|
169
|
+
`Column "${table}.${column}" is required`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
columns.push(column);
|
|
174
|
+
bindings.push(encodeValue(`${table}.${column}`, meta, value));
|
|
175
|
+
}
|
|
176
|
+
return { key: key!, columns, bindings };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
insert(
|
|
180
|
+
table: string,
|
|
181
|
+
values: Record<string, unknown>,
|
|
182
|
+
txnId?: string,
|
|
183
|
+
): AppletChangeV1 {
|
|
184
|
+
const definition = this.definition(table);
|
|
185
|
+
const { key, columns, bindings } = this.encodeInsert(
|
|
186
|
+
table,
|
|
187
|
+
definition,
|
|
188
|
+
values,
|
|
189
|
+
);
|
|
190
|
+
this.sql.exec(
|
|
191
|
+
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) ` +
|
|
192
|
+
`VALUES (${columns.map(() => "?").join(", ")})`,
|
|
193
|
+
...bindings,
|
|
194
|
+
);
|
|
195
|
+
const row = this.read(table, key);
|
|
196
|
+
if (!row)
|
|
197
|
+
throw new AppletValidationError(`Insert into "${table}" did not persist`);
|
|
198
|
+
return this.log({ table, op: "insert", key, row }, txnId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
update(
|
|
202
|
+
table: string,
|
|
203
|
+
key: string,
|
|
204
|
+
patch: Record<string, unknown>,
|
|
205
|
+
txnId?: string,
|
|
206
|
+
): AppletChangeV1 | undefined {
|
|
207
|
+
const definition = this.definition(table);
|
|
208
|
+
const assignments: string[] = [];
|
|
209
|
+
const bindings: SqlValue[] = [];
|
|
210
|
+
for (const [column, value] of Object.entries(patch)) {
|
|
211
|
+
if (!Object.hasOwn(definition.columns, column)) {
|
|
212
|
+
throw new AppletValidationError(`Unknown column "${table}.${column}"`);
|
|
213
|
+
}
|
|
214
|
+
if (column === definition.primaryKey) {
|
|
215
|
+
throw new AppletValidationError(
|
|
216
|
+
`Column "${table}.${column}" is the key`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
assignments.push(`${quoteIdentifier(column)} = ?`);
|
|
220
|
+
bindings.push(
|
|
221
|
+
encodeValue(
|
|
222
|
+
`${table}.${column}`,
|
|
223
|
+
definition.columns[column]!.definition,
|
|
224
|
+
value,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (assignments.length === 0) return undefined;
|
|
229
|
+
if (!this.read(table, key)) return undefined;
|
|
230
|
+
this.sql.exec(
|
|
231
|
+
`UPDATE ${quoteIdentifier(table)} SET ${assignments.join(", ")} ` +
|
|
232
|
+
`WHERE ${quoteIdentifier(definition.primaryKey)} = ?`,
|
|
233
|
+
...bindings,
|
|
234
|
+
key,
|
|
235
|
+
);
|
|
236
|
+
const row = this.read(table, key);
|
|
237
|
+
if (!row) return undefined;
|
|
238
|
+
return this.log({ table, op: "update", key, row }, txnId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
delete(
|
|
242
|
+
table: string,
|
|
243
|
+
key: string,
|
|
244
|
+
txnId?: string,
|
|
245
|
+
): AppletChangeV1 | undefined {
|
|
246
|
+
const definition = this.definition(table);
|
|
247
|
+
if (!this.read(table, key)) return undefined;
|
|
248
|
+
this.sql.exec(
|
|
249
|
+
`DELETE FROM ${quoteIdentifier(table)} WHERE ${quoteIdentifier(definition.primaryKey)} = ?`,
|
|
250
|
+
key,
|
|
251
|
+
);
|
|
252
|
+
return this.log({ table, op: "delete", key }, txnId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
read(table: string, key: string): Record<string, unknown> | undefined {
|
|
256
|
+
const definition = this.definition(table);
|
|
257
|
+
const rows = this.sql
|
|
258
|
+
.exec(
|
|
259
|
+
`SELECT * FROM ${quoteIdentifier(table)} WHERE ${quoteIdentifier(definition.primaryKey)} = ? LIMIT 1`,
|
|
260
|
+
key,
|
|
261
|
+
)
|
|
262
|
+
.toArray();
|
|
263
|
+
return rows.length === 0 ? undefined : this.decodeRow(definition, rows[0]!);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Every row, or the rows whose declared columns all equal `filter`. */
|
|
267
|
+
select(
|
|
268
|
+
table: string,
|
|
269
|
+
filter?: Record<string, unknown>,
|
|
270
|
+
): Array<Record<string, unknown>> {
|
|
271
|
+
const definition = this.definition(table);
|
|
272
|
+
const clauses: string[] = [];
|
|
273
|
+
const bindings: SqlValue[] = [];
|
|
274
|
+
for (const [column, value] of Object.entries(filter ?? {})) {
|
|
275
|
+
if (!Object.hasOwn(definition.columns, column)) {
|
|
276
|
+
throw new AppletValidationError(`Unknown column "${table}.${column}"`);
|
|
277
|
+
}
|
|
278
|
+
const encoded = encodeValue(
|
|
279
|
+
`${table}.${column}`,
|
|
280
|
+
definition.columns[column]!.definition,
|
|
281
|
+
value,
|
|
282
|
+
);
|
|
283
|
+
if (encoded === null) {
|
|
284
|
+
clauses.push(`${quoteIdentifier(column)} IS NULL`);
|
|
285
|
+
} else {
|
|
286
|
+
clauses.push(`${quoteIdentifier(column)} = ?`);
|
|
287
|
+
bindings.push(encoded);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`;
|
|
291
|
+
return this.sql
|
|
292
|
+
.exec(`SELECT * FROM ${quoteIdentifier(table)}${where}`, ...bindings)
|
|
293
|
+
.toArray()
|
|
294
|
+
.map((row) => this.decodeRow(definition, row));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Apply one client transaction. The caller wraps this in a SQL transaction. */
|
|
298
|
+
applyMutations(
|
|
299
|
+
mutations: AppletMutationV1[],
|
|
300
|
+
txnId?: string,
|
|
301
|
+
): AppletChangeV1[] {
|
|
302
|
+
return mutations.map((mutation) => {
|
|
303
|
+
const change =
|
|
304
|
+
mutation.op === "insert"
|
|
305
|
+
? this.insert(
|
|
306
|
+
mutation.table,
|
|
307
|
+
mutation.key === undefined
|
|
308
|
+
? (mutation.value ?? {})
|
|
309
|
+
: {
|
|
310
|
+
...(mutation.value ?? {}),
|
|
311
|
+
[this.definition(mutation.table).primaryKey]: mutation.key,
|
|
312
|
+
},
|
|
313
|
+
txnId,
|
|
314
|
+
)
|
|
315
|
+
: mutation.op === "update"
|
|
316
|
+
? this.update(
|
|
317
|
+
mutation.table,
|
|
318
|
+
mutation.key!,
|
|
319
|
+
mutation.value ?? {},
|
|
320
|
+
txnId,
|
|
321
|
+
)
|
|
322
|
+
: this.delete(mutation.table, mutation.key!, txnId);
|
|
323
|
+
if (!change) {
|
|
324
|
+
throw new AppletValidationError(
|
|
325
|
+
`Row "${mutation.key}" is not in "${mutation.table}"`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return change;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private log(change: AppletChangeV1, txnId?: string): AppletChangeV1 {
|
|
333
|
+
this.sql.exec(
|
|
334
|
+
`INSERT INTO ${quoteIdentifier(CHANGE_TABLE)} ("txnId", "tbl", "op", "rowKey", "row", "at") VALUES (?, ?, ?, ?, ?, ?)`,
|
|
335
|
+
txnId ?? null,
|
|
336
|
+
change.table,
|
|
337
|
+
change.op,
|
|
338
|
+
change.key,
|
|
339
|
+
change.row === undefined ? null : JSON.stringify(change.row),
|
|
340
|
+
new Date().toISOString(),
|
|
341
|
+
);
|
|
342
|
+
this.cursor = Number(
|
|
343
|
+
this.sql
|
|
344
|
+
.exec(`SELECT MAX("seq") AS seq FROM ${quoteIdentifier(CHANGE_TABLE)}`)
|
|
345
|
+
.toArray()[0]!.seq,
|
|
346
|
+
);
|
|
347
|
+
this.trim();
|
|
348
|
+
return change;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private trim(): void {
|
|
352
|
+
if (this.cursor <= CHANGE_LOG_LIMIT) return;
|
|
353
|
+
this.sql.exec(
|
|
354
|
+
`DELETE FROM ${quoteIdentifier(CHANGE_TABLE)} WHERE "seq" <= ?`,
|
|
355
|
+
this.cursor - CHANGE_LOG_LIMIT,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The whole state, table by table, in declaration order. */
|
|
360
|
+
snapshot(): Record<string, Array<Record<string, unknown>>> {
|
|
361
|
+
const tables: Record<string, Array<Record<string, unknown>>> = {};
|
|
362
|
+
for (const name of Object.keys(this.tables))
|
|
363
|
+
tables[name] = this.select(name);
|
|
364
|
+
return tables;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Changes after `cursor`, or `undefined` when the log no longer reaches back
|
|
369
|
+
* that far and the client must take a full snapshot instead.
|
|
370
|
+
*/
|
|
371
|
+
changesSince(cursor: number): AppletChangeV1[] | undefined {
|
|
372
|
+
if (cursor > this.cursor) return undefined;
|
|
373
|
+
if (cursor === this.cursor) return [];
|
|
374
|
+
const oldest = Number(
|
|
375
|
+
this.sql
|
|
376
|
+
.exec(`SELECT MIN("seq") AS seq FROM ${quoteIdentifier(CHANGE_TABLE)}`)
|
|
377
|
+
.toArray()[0]?.seq ?? 0,
|
|
378
|
+
);
|
|
379
|
+
if (oldest === 0 || oldest > cursor + 1) return undefined;
|
|
380
|
+
return this.sql
|
|
381
|
+
.exec(
|
|
382
|
+
`SELECT "tbl", "op", "rowKey", "row" FROM ${quoteIdentifier(CHANGE_TABLE)} WHERE "seq" > ? ORDER BY "seq" ASC`,
|
|
383
|
+
cursor,
|
|
384
|
+
)
|
|
385
|
+
.toArray()
|
|
386
|
+
.map((entry) => {
|
|
387
|
+
const change: AppletChangeV1 = {
|
|
388
|
+
table: String(entry.tbl),
|
|
389
|
+
op: String(entry.op) as AppletChangeV1["op"],
|
|
390
|
+
key: String(entry.rowKey),
|
|
391
|
+
};
|
|
392
|
+
if (entry.row !== null && entry.row !== undefined) {
|
|
393
|
+
change.row = JSON.parse(String(entry.row)) as Record<string, unknown>;
|
|
394
|
+
}
|
|
395
|
+
return change;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# __APPLET_NAME__
|
|
2
|
+
|
|
3
|
+
A FrockBot Applet. Two files are yours:
|
|
4
|
+
|
|
5
|
+
| File | What it owns |
|
|
6
|
+
| ----------- | -------------------------------------------------------------------------- |
|
|
7
|
+
| `server.ts` | the tables (state that survives every code change) and the tools Bots call |
|
|
8
|
+
| `ui.tsx` | the page the User sees beside the conversation |
|
|
9
|
+
|
|
10
|
+
## The loop
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
applet check # type-check and lint; every problem prints as path:line:col message
|
|
14
|
+
applet build # dist/server.js, dist/ui.html, dist/manifest.json
|
|
15
|
+
applet dev # serves the built Applet; prints a URL, opens nothing
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open the printed URL in the Computer's browser to look at it. Publish with
|
|
19
|
+
`applet_publish` once `applet check` is clean.
|
|
20
|
+
|
|
21
|
+
## Rules the linter enforces
|
|
22
|
+
|
|
23
|
+
- Colours come from the nine `--frockbot-*` theme tokens. The kit's components
|
|
24
|
+
already use them; never write `#hex`, `rgb(...)`, or a colour name.
|
|
25
|
+
- No `fetch`, `XMLHttpRequest`, or `WebSocket`. The Applet has no outbound
|
|
26
|
+
network: reach the world through a tool on the server.
|
|
27
|
+
- Import only from `@frockbot/applet-sdk/*`, `react`, and your own files.
|
|
28
|
+
- Declare tables with `table({ ... })` and tools with `this.tool({ ... }, fn)`.
|
|
29
|
+
|
|
30
|
+
## Changing the schema
|
|
31
|
+
|
|
32
|
+
Add a column with `.default(...)` or `.optional()` and the SDK adds it on the
|
|
33
|
+
next mount, keeping the rows. For anything else — renaming, rewriting values —
|
|
34
|
+
override `migrate(from)` on the class; it runs once, before the Applet serves
|
|
35
|
+
anything, and throwing fails the mount back to the last known-good generation.
|
|
36
|
+
|
|
37
|
+
The kit's components and their props are documented in the Applets Skill.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Applet, t, table } from "@frockbot/applet-sdk/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The schema. It becomes the SQLite tables, the wire format, and the client's
|
|
5
|
+
* collections, and it survives every publish: changing this file's code never
|
|
6
|
+
* clears the rows.
|
|
7
|
+
*/
|
|
8
|
+
const tables = {
|
|
9
|
+
todos: table({
|
|
10
|
+
id: t.id(),
|
|
11
|
+
title: t.text(),
|
|
12
|
+
done: t.boolean().default(false),
|
|
13
|
+
createdAt: t.timestamp(),
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The server half of __APPLET_NAME__.
|
|
19
|
+
*
|
|
20
|
+
* `tools` is what every Bot of this User can call. `this.db` is the only way
|
|
21
|
+
* to read or write, and each call is atomic.
|
|
22
|
+
*/
|
|
23
|
+
export default class TodoApplet extends Applet<typeof tables> {
|
|
24
|
+
tables = tables;
|
|
25
|
+
|
|
26
|
+
tools = {
|
|
27
|
+
add_todo: this.tool(
|
|
28
|
+
{ description: "Add a todo to the list", input: { title: t.text() } },
|
|
29
|
+
({ title }) => {
|
|
30
|
+
this.db.todos.insert({ title, createdAt: new Date().toISOString() });
|
|
31
|
+
return `Added "${title}".`;
|
|
32
|
+
},
|
|
33
|
+
),
|
|
34
|
+
list_todos: this.tool(
|
|
35
|
+
{ description: "List the todos, open ones first", input: {} },
|
|
36
|
+
() => {
|
|
37
|
+
const todos = this.db.todos.select();
|
|
38
|
+
if (todos.length === 0) return "The list is empty.";
|
|
39
|
+
return todos
|
|
40
|
+
.sort((left, right) => Number(left.done) - Number(right.done))
|
|
41
|
+
.map((todo) => `${todo.done ? "[x]" : "[ ]"} ${todo.title}`)
|
|
42
|
+
.join("\n");
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
}
|
package/template/ui.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { createApplet, mount, newId } from "@frockbot/applet-sdk/client";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Input,
|
|
8
|
+
List,
|
|
9
|
+
ListItem,
|
|
10
|
+
Stack,
|
|
11
|
+
Text,
|
|
12
|
+
Toolbar,
|
|
13
|
+
} from "@frockbot/applet-sdk/kit";
|
|
14
|
+
|
|
15
|
+
import type TodoApplet from "./server";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The page half of __APPLET_NAME__.
|
|
19
|
+
*
|
|
20
|
+
* `createApplet` connects when the host sends its `init` message, so there is
|
|
21
|
+
* no loading wiring to write. Every mutation is optimistic: the row appears at
|
|
22
|
+
* once and rolls back on its own if the server rejects it.
|
|
23
|
+
*/
|
|
24
|
+
const applet = createApplet<TodoApplet>();
|
|
25
|
+
|
|
26
|
+
function App() {
|
|
27
|
+
const [draft, setDraft] = useState("");
|
|
28
|
+
const { status } = applet.useApplet();
|
|
29
|
+
const { data: todos } = applet.useLiveQuery((query) =>
|
|
30
|
+
query
|
|
31
|
+
.from({ todo: applet.tables.todos })
|
|
32
|
+
.orderBy(({ todo }) => todo.createdAt),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const add = () => {
|
|
36
|
+
const title = draft.trim();
|
|
37
|
+
if (title === "") return;
|
|
38
|
+
applet.tables.todos.insert({
|
|
39
|
+
id: newId(),
|
|
40
|
+
title,
|
|
41
|
+
done: false,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
});
|
|
44
|
+
setDraft("");
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Stack root gap="large">
|
|
49
|
+
<Toolbar
|
|
50
|
+
end={
|
|
51
|
+
<Text size="small" tone="muted">
|
|
52
|
+
{status}
|
|
53
|
+
</Text>
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
<Text size="title">__APPLET_NAME__</Text>
|
|
57
|
+
</Toolbar>
|
|
58
|
+
|
|
59
|
+
<Stack direction="row" gap="small" align="end">
|
|
60
|
+
<Input
|
|
61
|
+
label="New todo"
|
|
62
|
+
value={draft}
|
|
63
|
+
placeholder="Buy milk"
|
|
64
|
+
onValueChange={setDraft}
|
|
65
|
+
onKeyDown={(event) => {
|
|
66
|
+
if (event.key === "Enter") add();
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
<Button variant="primary" onClick={add}>
|
|
70
|
+
Add
|
|
71
|
+
</Button>
|
|
72
|
+
</Stack>
|
|
73
|
+
|
|
74
|
+
{todos.length === 0 ? (
|
|
75
|
+
<EmptyState
|
|
76
|
+
title="Nothing yet"
|
|
77
|
+
description="Add your first todo above."
|
|
78
|
+
/>
|
|
79
|
+
) : (
|
|
80
|
+
<List>
|
|
81
|
+
{todos.map((todo) => (
|
|
82
|
+
<ListItem
|
|
83
|
+
key={todo.id}
|
|
84
|
+
start={
|
|
85
|
+
<Checkbox
|
|
86
|
+
checked={todo.done}
|
|
87
|
+
ariaLabel={`Mark "${todo.title}" done`}
|
|
88
|
+
onChange={(done) =>
|
|
89
|
+
applet.tables.todos.update(todo.id, (draftRow) => {
|
|
90
|
+
draftRow.done = done;
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
}
|
|
95
|
+
end={
|
|
96
|
+
<Button
|
|
97
|
+
variant="ghost"
|
|
98
|
+
onClick={() => applet.tables.todos.delete(todo.id)}
|
|
99
|
+
>
|
|
100
|
+
Delete
|
|
101
|
+
</Button>
|
|
102
|
+
}
|
|
103
|
+
>
|
|
104
|
+
<Text tone={todo.done ? "muted" : "default"}>{todo.title}</Text>
|
|
105
|
+
</ListItem>
|
|
106
|
+
))}
|
|
107
|
+
</List>
|
|
108
|
+
)}
|
|
109
|
+
</Stack>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mount(<App />);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only part of the Cloudflare programming model the SDK names.
|
|
3
|
+
*
|
|
4
|
+
* ADR 0022 records the ceiling deliberately: an Applet is a Durable Object with
|
|
5
|
+
* alarms and hibernating sockets, and no adapter hides that. What this file
|
|
6
|
+
* does is keep the surface to the handful of members `server/` actually uses,
|
|
7
|
+
* so an Applet author never types a binding name and the SDK type-checks
|
|
8
|
+
* without `@cloudflare/workers-types` in scope. It lives outside `src/` so a
|
|
9
|
+
* consumer that already has the real Workers types cannot see two declarations
|
|
10
|
+
* of the same module.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
declare module "cloudflare:workers" {
|
|
14
|
+
export interface AppletSqlCursor {
|
|
15
|
+
toArray(): Array<Record<string, unknown>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AppletSqlStorageHandle {
|
|
19
|
+
exec(query: string, ...bindings: unknown[]): AppletSqlCursor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AppletDurableObjectStorage {
|
|
23
|
+
readonly sql: AppletSqlStorageHandle;
|
|
24
|
+
transactionSync<T>(closure: () => T): T;
|
|
25
|
+
deleteAll(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AppletHibernatableWebSocket {
|
|
29
|
+
send(message: string): void;
|
|
30
|
+
close(code?: number, reason?: string): void;
|
|
31
|
+
serializeAttachment(value: unknown): void;
|
|
32
|
+
deserializeAttachment(): unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AppletDurableObjectState {
|
|
36
|
+
readonly id: { toString(): string; readonly name?: string };
|
|
37
|
+
readonly storage: AppletDurableObjectStorage;
|
|
38
|
+
acceptWebSocket(socket: AppletHibernatableWebSocket, tags?: string[]): void;
|
|
39
|
+
getWebSockets(tag?: string): AppletHibernatableWebSocket[];
|
|
40
|
+
blockConcurrencyWhile<T>(closure: () => Promise<T>): Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class DurableObject<Env = unknown> {
|
|
44
|
+
constructor(ctx: AppletDurableObjectState, env: Env);
|
|
45
|
+
protected readonly ctx: AppletDurableObjectState;
|
|
46
|
+
protected readonly env: Env;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
declare const WebSocketPair: {
|
|
51
|
+
new (): {
|
|
52
|
+
0: import("cloudflare:workers").AppletHibernatableWebSocket;
|
|
53
|
+
1: import("cloudflare:workers").AppletHibernatableWebSocket;
|
|
54
|
+
};
|
|
55
|
+
};
|