cloesce 0.0.5-unstable.4 → 0.0.5-unstable.5

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.
@@ -1,60 +1,124 @@
1
- import { Either, u8ToB64 } from "./common.js";
2
- import { RuntimeContainer } from "../router/router.js";
3
- import { WasmResource, mapSql, invokeOrmWasm } from "../router/wasm.js";
1
+ import { MediaType } from "../ast.js";
2
+ import { u8ToB64 } from "../common.js";
4
3
  /**
5
4
  * cloesce/backend
6
5
  */
7
- export { CloesceApp, } from "../router/router.js";
8
- export { HttpResult, Either } from "./common.js";
6
+ export { CloesceApp } from "../router/router.js";
7
+ export { Orm } from "../router/orm.js";
9
8
  /**
10
- * Marks a class as a D1-backed SQL model.
9
+ * Base class for a Cloudflare KV model or navigation property.
11
10
  *
12
- * Classes annotated with `@D1` are compiled into:
13
- * - a D1 table definition (via `cloesce migrate`)
14
- * - backend API endpoints (Workers)
15
- * - a frontend client API
16
- * - Cloudflare Wrangler configurations
11
+ * Consists of a `key`, `value`, and optional `metadata`.
17
12
  *
18
- * Each `@D1` class must define exactly one `@PrimaryKey`.
13
+ * @template V The type of the value stored in the KValue. Note that KV is schema-less,
14
+ * so this type is not enforced at runtime, but serves as the type the client expects.
19
15
  *
20
- * Example:
21
- *```ts
22
- * @D1
23
- * export class Horse {
24
- * @PrimaryKey id: number;
25
- * name: string;
26
- * }
27
- * ```
16
+ * @remarks
17
+ * - The `key` is a string that uniquely identifies the entry in the KV store.
18
+ * - The `value` is of generic type `V`, allowing flexibility in the type of data stored.
19
+ * - `V` must be serializable to JSON.
20
+ * - The `metadata` can hold any additional information associated with the KV entry.
28
21
  */
29
- export const D1 = () => { };
30
- export const Service = () => { };
22
+ export class KValue {
23
+ key;
24
+ raw;
25
+ metadata;
26
+ get value() {
27
+ return this.raw;
28
+ }
29
+ }
31
30
  /**
32
- * Marks a class as a plain serializable object.
33
- *
34
- * `@PlainOldObject` types represent data that can be safely
35
- * returned from a model method or API endpoint without being
36
- * treated as a database model.
37
- *
38
- * Example:
39
- * ```ts
40
- * @PlainOldObject
41
- * export class CatStuff {
42
- * catFacts: string[];
43
- * catNames: string[];
44
- * }
45
- *
46
- * // in a method...
47
- * foo(): CatStuff {
48
- * return {
49
- * catFacts: ["cats sleep 16 hours a day"],
50
- * catNames: ["Whiskers", "Fluffy"]
51
- * };
52
- *
53
- * // which generates an API like:
54
- * async function foo(): Promise<HttpResult<CatStuff>> { ... }
55
- * ```
56
- */
57
- export const PlainOldObject = () => { };
31
+ * The result of a Workers endpoint.
32
+ *
33
+ * @param ok True if `status` < 400
34
+ * @param status The HTTP Status of a Workers request
35
+ * @param headers All headers that the result is to be sent with or was received with
36
+ * @param data JSON data yielded from a request, undefined if the request was not `ok`.
37
+ * @param message An error text set if the request was not `ok`.
38
+ *
39
+ * @remarks If `status` is 204 `data` will always be undefined.
40
+ *
41
+ */
42
+ export class HttpResult {
43
+ ok;
44
+ status;
45
+ headers;
46
+ data;
47
+ message;
48
+ mediaType;
49
+ constructor(ok, status, headers, data, message, mediaType) {
50
+ this.ok = ok;
51
+ this.status = status;
52
+ this.headers = headers;
53
+ this.data = data;
54
+ this.message = message;
55
+ this.mediaType = mediaType;
56
+ }
57
+ static ok(status, data, init) {
58
+ const headers = new Headers(init);
59
+ return new HttpResult(true, status, headers, data, undefined);
60
+ }
61
+ static fail(status, message, init) {
62
+ const headers = new Headers(init);
63
+ return new HttpResult(false, status, headers, undefined, message);
64
+ }
65
+ toResponse() {
66
+ let body;
67
+ switch (this.mediaType) {
68
+ case MediaType.Json: {
69
+ this.headers.set("Content-Type", "application/json");
70
+ body = JSON.stringify(this.data ?? {}, (_, v) => {
71
+ // Convert Uint8Arrays to base64 strings
72
+ if (v instanceof Uint8Array) {
73
+ return u8ToB64(v);
74
+ }
75
+ // Convert R2Object to Client R2Object representation
76
+ if (isR2Object(v)) {
77
+ return {
78
+ key: v.key,
79
+ version: v.version,
80
+ size: v.size,
81
+ etag: v.etag,
82
+ httpEtag: v.httpEtag,
83
+ uploaded: v.uploaded.toISOString(),
84
+ customMetadata: v.customMetadata,
85
+ };
86
+ }
87
+ if (v instanceof Date) {
88
+ return v.toISOString();
89
+ }
90
+ return v;
91
+ });
92
+ break;
93
+ }
94
+ case MediaType.Octet: {
95
+ this.headers.set("Content-Type", "application/octet-stream");
96
+ // JSON structure isn't needed; assume the first
97
+ // value is the stream data
98
+ body = Object.values(this.data ?? {})[0];
99
+ break;
100
+ }
101
+ case undefined: {
102
+ // Errors are always text.
103
+ this.headers.set("Content-Type", "text/plain");
104
+ return new Response(this.message, {
105
+ status: this.status,
106
+ headers: this.headers,
107
+ });
108
+ }
109
+ }
110
+ return new Response(body, {
111
+ status: this.status,
112
+ headers: this.headers,
113
+ });
114
+ }
115
+ setMediaType(mediaType) {
116
+ this.mediaType = mediaType;
117
+ return this;
118
+ }
119
+ }
120
+ export const Model = (_kinds = []) => () => { };
121
+ export const Service = () => { };
58
122
  /**
59
123
  * Declares a Wrangler environment definition.
60
124
  *
@@ -94,6 +158,9 @@ export const WranglerEnv = () => { };
94
158
  * ```
95
159
  */
96
160
  export const PrimaryKey = () => { };
161
+ export const KeyParam = () => { };
162
+ export const KV = (_keyFormat, _namespaceBinding) => () => { };
163
+ export const R2 = (_keyFormat, _bucketBinding) => () => { };
97
164
  /**
98
165
  * Exposes a class method as an HTTP GET endpoint.
99
166
  * The method will appear in both backend and generated client APIs.
@@ -119,100 +186,12 @@ export const PATCH = () => { };
119
186
  * The method will appear in both backend and generated client APIs.
120
187
  */
121
188
  export const DELETE = () => { };
122
- /**
123
- * Declares a static property as a data source.
124
- *
125
- * Data sources describe SQL left joins related to each
126
- * models navigation properties.
127
- *
128
- * Example:
129
- * ```ts
130
- * @D1
131
- * export class Dog {
132
- * @PrimaryKey
133
- * id: number;
134
- *
135
- * name: string;
136
- * }
137
- *
138
- * @D1
139
- * export class Person {
140
- * @PrimaryKey
141
- * id: number;
142
- *
143
- * @ForeignKey(Dog)
144
- * dogId: number;
145
- *
146
- * @OneToOne("dogId")
147
- * dog: Dog | undefined;
148
- *
149
- * @DataSource
150
- * static readonly default: IncludeTree<Person> = {
151
- * dog: {}, // join Dog table when querying Person with `default` data source
152
- * };
153
- * }
154
- *
155
- * // When queried via the ORM or client API:
156
- * const orm = Orm.fromD1(env.db);
157
- * const people = await orm.list(Person, Person.default);
158
- *
159
- * // => Person { id: 1, dogId: 2, dog: { id: 2, name: "Fido" } }[]
160
- * ```
161
- */
162
- export const DataSource = () => { };
163
- /**
164
- * Declares a one-to-many relationship between models.
165
- *
166
- * The argument is the foreign key property name on the
167
- * related model.
168
- *
169
- * Example:
170
- * ```ts
171
- * @OneToMany("personId")
172
- * dogs: Dog[];
173
- * ```
174
- */
175
- export const OneToMany = (_foreignKeyColumn) => () => { };
176
- /**
177
- * Declares a one-to-one relationship between models.
178
- *
179
- * The argument is the foreign key property name that links
180
- * the two tables.
181
- *
182
- * Example:
183
- * ```ts
184
- * @OneToOne("dogId")
185
- * dog: Dog | undefined;
186
- * ```
187
- */
188
- export const OneToOne = (_foreignKeyColumn) => () => { };
189
- /**
190
- * Declares a many-to-many relationship between models.
191
- *
192
- * The argument is a unique identifier for the generated
193
- * junction table used to connect the two entities.
194
- *
195
- * Example:
196
- * ```ts
197
- * @ManyToMany("StudentsCourses")
198
- * courses: Course[];
199
- * ```
200
- */
201
- export const ManyToMany = (_uniqueId) => () => { };
202
- /**
203
- * Declares a foreign key relationship between models.
204
- * Directly translates to a SQLite foreign key.
205
- *
206
- * The argument must reference either a model class or the
207
- * name of a model class as a string. The property type must
208
- * match the target model’s primary key type.
209
- *
210
- * Example:
211
- * ```ts
212
- * @ForeignKey(Dog)
213
- * dogId: number;
214
- * ```
215
- */
189
+ export function OneToMany(_selector) {
190
+ return () => { };
191
+ }
192
+ export function OneToOne(_selector) {
193
+ return () => { };
194
+ }
216
195
  export const ForeignKey = (_Model) => () => { };
217
196
  /**
218
197
  * Marks a method parameter for dependency injection.
@@ -232,271 +211,18 @@ export const ForeignKey = (_Model) => () => { };
232
211
  * ```
233
212
  */
234
213
  export const Inject = () => { };
235
- /**
236
- * Enables automatic CRUD method generation for a model.
237
- *
238
- * The argument is a list of CRUD operation kinds
239
- * (e.g. `"SAVE"`, `"GET"`, `"LIST"`) to generate for the model.
240
- *
241
- * Cloesce will emit corresponding backend methods and frontend
242
- * client bindings automatically, removing the need to manually
243
- * define common API operations.
244
- *
245
- * CRUD Operations:
246
- * - **"SAVE"** — Performs an *upsert* (insert, update, or both) for a model instance.
247
- * - **"GET"** — Retrieves a single record by its primary key, optionally using a `DataSource`.
248
- * - **"LIST"** — Retrieves all records for the model, using the specified `DataSource`.
249
- *
250
- * The generated methods are static, exposed through both the backend
251
- * and the frontend client API.
252
- *
253
- * Example:
254
- * ```ts
255
- * @CRUD(["SAVE", "GET", "LIST"])
256
- * @D1
257
- * export class CrudHaver {
258
- * @PrimaryKey id: number;
259
- * name: string;
260
- * }
261
- * ```
262
- */
263
- export const CRUD = (_kinds) => () => { };
264
- /**
265
- * Exposes the ORM primitives Cloesce uses to interact with D1 databases.
266
- */
267
- export class Orm {
268
- db;
269
- constructor(db) {
270
- this.db = db;
271
- }
272
- /**
273
- * Creates an instance of an `Orm`
274
- * @param db The database to use for ORM calls.
275
- */
276
- static fromD1(db) {
277
- return new Orm(db);
278
- }
279
- /**
280
- * Maps SQL records to an instantiated Model. The records must be flat
281
- * (e.g., of the form "id, name, address") or derive from a Cloesce data source view
282
- * (e.g., of the form "Horse.id, Horse.name, Horse.address")
283
- *
284
- * Assumes the data is formatted correctly, throwing an error otherwise.
285
- *
286
- * @param ctor The model constructor
287
- * @param records D1 Result records
288
- * @param includeTree Include tree to define the relationships to join.
289
- */
290
- static mapSql(ctor, records, includeTree = null) {
291
- return mapSql(ctor, records, includeTree).unwrap();
292
- }
293
- /**
294
- * Executes an "upsert" query, adding or augmenting a model in the database.
295
- *
296
- * If a model's primary key is not defined in `newModel`, the query is assumed to be an insert.
297
- *
298
- * If a model's primary key _is_ defined, but some attributes are missing, the query is assumed to be an update.
299
- *
300
- * Finally, if the primary key is defined, but all attributes are included, a SQLite upsert will be performed.
301
- *
302
- * In any other case, an error string will be returned.
303
- *
304
- * ### Inserting a new Model
305
- * ```ts
306
- * const model = {name: "julio", lastname: "pumpkin"};
307
- * const idRes = await orm.upsert(Person, model, null);
308
- * ```
309
- *
310
- * ### Updating an existing model
311
- * ```ts
312
- * const model = {id: 1, name: "timothy"};
313
- * const idRes = await orm.upsert(Person, model, null);
314
- * // (in db)=> {id: 1, name: "timothy", lastname: "pumpkin"}
315
- * ```
316
- *
317
- * ### Upserting a model
318
- * ```ts
319
- * // (assume a Person already exists)
320
- * const model = {
321
- * id: 1,
322
- * lastname: "burger", // updates last name
323
- * dog: {
324
- * name: "fido" // insert dog relationship
325
- * }
326
- * };
327
- * const idRes = await orm.upsert(Person, model, null);
328
- * // (in db)=> Person: {id: 1, dogId: 1 ...} ; Dog: {id: 1, name: "fido"}
329
- * ```
330
- *
331
- * @param ctor A model constructor.
332
- * @param newModel The new or augmented model.
333
- * @param includeTree An include tree describing which foreign keys to join.
334
- * @returns An error string, or the primary key of the inserted model.
335
- */
336
- async upsert(ctor, newModel, includeTree = null) {
337
- const { wasm } = RuntimeContainer.get();
338
- const args = [
339
- WasmResource.fromString(ctor.name, wasm),
340
- WasmResource.fromString(JSON.stringify(newModel, (k, v) => v instanceof Uint8Array ? u8ToB64(v) : v), wasm),
341
- WasmResource.fromString(JSON.stringify(includeTree), wasm),
342
- ];
343
- const upsertQueryRes = invokeOrmWasm(wasm.upsert_model, args, wasm);
344
- if (upsertQueryRes.isLeft()) {
345
- return upsertQueryRes;
346
- }
347
- const statements = JSON.parse(upsertQueryRes.unwrap());
348
- // One of these statements is a "SELECT", which is the root model id stmt.
349
- let selectIndex;
350
- for (let i = statements.length - 1; i >= 0; i--) {
351
- if (/^SELECT/i.test(statements[i].query)) {
352
- selectIndex = i;
353
- break;
354
- }
355
- }
356
- // Execute all statements in a batch.
357
- const batchRes = await this.db.batch(statements.map((s) => this.db.prepare(s.query).bind(...s.values)));
358
- if (!batchRes.every((r) => r.success)) {
359
- const failed = batchRes.find((r) => !r.success);
360
- return Either.left(failed?.error ?? "D1 batch failed, but no error was returned.");
361
- }
362
- const rootModelId = batchRes[selectIndex].results[0];
363
- return Either.right(rootModelId.id);
364
- }
365
- /**
366
- * Returns a select query, creating a CTE view for the model using the provided include tree.
367
- *
368
- * @param ctor The model constructor.
369
- * @param includeTree An include tree describing which related models to join.
370
- * @param from An optional custom `FROM` clause to use instead of the base table.
371
- * @param tagCte An optional CTE name to tag the query with. Defaults to "Model.view".
372
- *
373
- * ### Example:
374
- * ```ts
375
- * // Using a data source
376
- * const query = Orm.listQuery(Person, "default");
377
- *
378
- * // Using a custom from statement
379
- * const query = Orm.listQuery(Person, null, "SELECT * FROM Person WHERE age > 18");
380
- * ```
381
- *
382
- * ### Example SQL output:
383
- * ```sql
384
- * WITH Person_view AS (
385
- * SELECT
386
- * "Person"."id" AS "id",
387
- * ...
388
- * FROM "Person"
389
- * LEFT JOIN ...
390
- * )
391
- * SELECT * FROM Person_view
392
- * ```
393
- */
394
- static listQuery(ctor, opts) {
395
- const { wasm } = RuntimeContainer.get();
396
- const args = [
397
- WasmResource.fromString(ctor.name, wasm),
398
- WasmResource.fromString(JSON.stringify(opts.includeTree ?? null), wasm),
399
- WasmResource.fromString(JSON.stringify(opts.tagCte ?? null), wasm),
400
- WasmResource.fromString(JSON.stringify(opts.from ?? null), wasm),
401
- ];
402
- const res = invokeOrmWasm(wasm.list_models, args, wasm);
403
- if (res.isLeft()) {
404
- throw new Error(`Error invoking the Cloesce WASM Binary: ${res.value}`);
405
- }
406
- return res.unwrap();
407
- }
408
- /**
409
- * Returns a select query for a single model by primary key, creating a CTE view using the provided include tree.
410
- *
411
- * @param ctor The model constructor.
412
- * @param includeTree An include tree describing which related models to join.
413
- *
414
- * ### Example:
415
- * ```ts
416
- * // Using a data source
417
- * const query = Orm.getQuery(Person, "default");
418
- * ```
419
- *
420
- * ### Example SQL output:
421
- *
422
- * ```sql
423
- * WITH Person_view AS (
424
- * SELECT
425
- * "Person"."id" AS "id",
426
- * ...
427
- * FROM "Person"
428
- * LEFT JOIN ...
429
- * )
430
- * SELECT * FROM Person_view WHERE [Person].[id] = ?
431
- * ```
432
- */
433
- static getQuery(ctor, includeTree) {
434
- const { ast } = RuntimeContainer.get();
435
- return `${this.listQuery(ctor, { includeTree })} WHERE [${ast.models[ctor.name].primary_key.name}] = ?`;
436
- }
437
- /**
438
- * Retrieves all instances of a model from the database.
439
- * @param ctor The model constructor.
440
- * @param includeTree An include tree describing which related models to join.
441
- * @param from An optional custom `FROM` clause to use instead of the base table.
442
- * @returns Either an error string, or an array of model instances.
443
- *
444
- * ### Example:
445
- * ```ts
446
- * const orm = Orm.fromD1(env.db);
447
- * const horses = await orm.list(Horse, Horse.default);
448
- * ```
449
- *
450
- * ### Example with custom from:
451
- * ```ts
452
- * const orm = Orm.fromD1(env.db);
453
- * const adultHorses = await orm.list(Horse, Horse.default, "SELECT * FROM Horse ORDER BY age DESC LIMIT 10");
454
- * ```
455
- *
456
- * =>
457
- *
458
- * ```sql
459
- * SELECT
460
- * "Horse"."id" AS "id",
461
- * ...
462
- * FROM (SELECT * FROM Horse ORDER BY age DESC LIMIT 10)
463
- * LEFT JOIN ...
464
- * ```
465
- *
466
- */
467
- async list(ctor, opts) {
468
- const sql = Orm.listQuery(ctor, opts);
469
- const stmt = this.db.prepare(sql);
470
- const records = await stmt.all();
471
- if (!records.success) {
472
- return Either.left(records.error ?? "D1 query failed, but no error was returned.");
473
- }
474
- const mapped = Orm.mapSql(ctor, records.results, opts.includeTree ?? null);
475
- return Either.right(mapped);
476
- }
477
- /**
478
- * Retrieves a single model by primary key.
479
- * @param ctor The model constructor.
480
- * @param id The primary key value.
481
- * @param includeTree An include tree describing which related models to join.
482
- * @returns Either an error string, or the model instance (null if not found).
483
- *
484
- * ### Example:
485
- * ```ts
486
- * const orm = Orm.fromD1(env.db);
487
- * const horse = await orm.get(Horse, 1, Horse.default);
488
- * ```
489
- */
490
- async get(ctor, id, includeTree) {
491
- const sql = Orm.getQuery(ctor, includeTree);
492
- const record = await this.db.prepare(sql).bind(id).run();
493
- if (!record.success) {
494
- return Either.left(record.error ?? "D1 query failed, but no error was returned.");
495
- }
496
- if (record.results.length === 0) {
497
- return Either.right(null);
498
- }
499
- const mapped = Orm.mapSql(ctor, record.results, includeTree);
500
- return Either.right(mapped[0]);
501
- }
214
+ /** Hack to detect R2Object at runtime */
215
+ function isR2Object(x) {
216
+ if (typeof x !== "object" || x === null)
217
+ return false;
218
+ const o = x;
219
+ return (typeof o.key === "string" &&
220
+ typeof o.version === "string" &&
221
+ typeof o.size === "number" &&
222
+ typeof o.etag === "string" &&
223
+ typeof o.httpEtag === "string" &&
224
+ typeof o.uploaded === "object" &&
225
+ typeof o.uploaded?.getTime === "function" &&
226
+ typeof o.storageClass === "string" &&
227
+ typeof o.writeHttpMetadata === "function");
502
228
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "cloesce",
3
- "version": "0.0.5-unstable.4",
3
+ "version": "0.0.5-unstable.5",
4
4
  "description": "A tool to extract and compile TypeScript code into something wrangler can consume and deploy for D1 Databases and Cloudflare Workers",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "scripts": {
8
- "test": "vitest",
8
+ "test": "vitest run",
9
9
  "format:fix": "prettier --write .",
10
10
  "format": "prettier --check .",
11
11
  "typecheck": "tsc --noEmit",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@cloudflare/workers-types": "^4.20250906.0",
23
+ "miniflare": "^4.20251217.0",
23
24
  "oxlint": "^1.32.0",
24
25
  "prettier": "^3.6.2",
25
26
  "ts-node": "^10.9.2",
@@ -27,10 +28,6 @@
27
28
  "vitest": "^3.2.4"
28
29
  },
29
30
  "exports": {
30
- "./client": {
31
- "types": "./dist/ui/client.d.ts",
32
- "import": "./dist/ui/client.js"
33
- },
34
31
  "./backend": {
35
32
  "types": "./dist/ui/backend.d.ts",
36
33
  "import": "./dist/ui/backend.js"
@@ -43,9 +40,6 @@
43
40
  "*": {
44
41
  "backend": [
45
42
  "dist/ui/backend.d.ts"
46
- ],
47
- "client": [
48
- "dist/ui/client.d.ts"
49
43
  ]
50
44
  }
51
45
  },
@@ -1,7 +0,0 @@
1
- /**
2
- * cloesce/client
3
- */
4
- export type { DeepPartial, Stream } from "./common.js";
5
- export { HttpResult, Either, requestBody, b64ToU8 } from "./common.js";
6
- export { MediaType } from "../ast.js";
7
- //# sourceMappingURL=client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/ui/client.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,YAAY,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACvD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC"}
package/dist/ui/client.js DELETED
@@ -1,2 +0,0 @@
1
- export { HttpResult, Either, requestBody, b64ToU8 } from "./common.js";
2
- export { MediaType } from "../ast.js";