cloesce 0.0.4-unstable.1 → 0.0.4-unstable.10

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,27 +1,264 @@
1
- import { left, right } from "../common.js";
1
+ import { Either } from "../common.js";
2
2
  import { RuntimeContainer } from "../router/router.js";
3
- import { WasmResource, fromSql, invokeOrmWasm } from "../router/wasm.js";
4
- export { cloesce } from "../router/router.js";
5
- export { CloesceApp } from "../common.js";
6
- // Compiler hints
3
+ import { WasmResource, mapSql, invokeOrmWasm } from "../router/wasm.js";
4
+ export { CloesceApp } from "../router/router.js";
5
+ export { HttpResult, Either } from "../common.js";
6
+ /**
7
+ * Marks a class as a D1-backed SQL model.
8
+ *
9
+ * Classes annotated with `@D1` are compiled into:
10
+ * - a D1 table definition (via `cloesce migrate`)
11
+ * - backend API endpoints (Workers)
12
+ * - a frontend client API
13
+ * - Cloudflare Wrangler configurations
14
+ *
15
+ * Each `@D1` class must define exactly one `@PrimaryKey`.
16
+ *
17
+ * Example:
18
+ *```ts
19
+ * @D1
20
+ * export class Horse {
21
+ * @PrimaryKey id: number;
22
+ * name: string;
23
+ * }
24
+ * ```
25
+ */
7
26
  export const D1 = () => { };
27
+ /**
28
+ * Marks a class as a plain serializable object.
29
+ *
30
+ * `@PlainOldObject` types represent data that can be safely
31
+ * returned from a model method or API endpoint without being
32
+ * treated as a database model.
33
+ *
34
+ * Example:
35
+ * ```ts
36
+ * @PlainOldObject
37
+ * export class CatStuff {
38
+ * catFacts: string[];
39
+ * catNames: string[];
40
+ * }
41
+ *
42
+ * // in a method...
43
+ * foo(): CatStuff {
44
+ * return {
45
+ * catFacts: ["cats sleep 16 hours a day"],
46
+ * catNames: ["Whiskers", "Fluffy"]
47
+ * };
48
+ *
49
+ * // which generates an API like:
50
+ * async function foo(): Promise<HttpResult<CatStuff>> { ... }
51
+ * ```
52
+ */
8
53
  export const PlainOldObject = () => { };
54
+ /**
55
+ * Declares a Wrangler environment definition.
56
+ *
57
+ * A `@WranglerEnv` class describes environment bindings
58
+ * available to your Cloudflare Worker at runtime.
59
+ *
60
+ * The environment instance is automatically injected into
61
+ * decorated methods using `@Inject`.
62
+ *
63
+ * Example:
64
+ * ```ts
65
+ * @WranglerEnv
66
+ * export class Env {
67
+ * db: D1Database;
68
+ * motd: string;
69
+ * }
70
+ *
71
+ * // in a method...
72
+ * foo(@Inject env: WranglerEnv) {...}
73
+ * ```
74
+ */
9
75
  export const WranglerEnv = () => { };
76
+ /**
77
+ * Marks a property as the SQL primary key for a model.
78
+ *
79
+ * Every `@D1` class must define exactly one primary key.
80
+ *
81
+ * Cannot be null.
82
+ *
83
+ * Example:
84
+ * ```ts
85
+ * @D1
86
+ * export class User {
87
+ * @PrimaryKey id: number;
88
+ * name: string;
89
+ * }
90
+ * ```
91
+ */
10
92
  export const PrimaryKey = () => { };
93
+ /**
94
+ * Exposes a class method as an HTTP GET endpoint.
95
+ * The method will appear in both backend and generated client APIs.
96
+ */
11
97
  export const GET = () => { };
98
+ /**
99
+ * Exposes a class method as an HTTP POST endpoint.
100
+ * The method will appear in both backend and generated client APIs.
101
+ */
12
102
  export const POST = () => { };
103
+ /**
104
+ * Exposes a class method as an HTTP PUT endpoint.
105
+ * The method will appear in both backend and generated client APIs.
106
+ */
13
107
  export const PUT = () => { };
108
+ /**
109
+ * Exposes a class method as an HTTP PATCH endpoint.
110
+ * The method will appear in both backend and generated client APIs.
111
+ */
14
112
  export const PATCH = () => { };
113
+ /**
114
+ * Exposes a class method as an HTTP DEL endpoint.
115
+ * The method will appear in both backend and generated client APIs.
116
+ */
15
117
  export const DELETE = () => { };
118
+ /**
119
+ * Declares a static property as a data source.
120
+ *
121
+ * Data sources describe SQL left joins related to each
122
+ * models navigation properties.
123
+ *
124
+ * Example:
125
+ * ```ts
126
+ * @D1
127
+ * export class Dog {
128
+ * @PrimaryKey
129
+ * id: number;
130
+ *
131
+ * name: string;
132
+ * }
133
+ *
134
+ * @D1
135
+ * export class Person {
136
+ * @PrimaryKey
137
+ * id: number;
138
+ *
139
+ * @ForeignKey(Dog)
140
+ * dogId: number;
141
+ *
142
+ * @OneToOne("dogId")
143
+ * dog: Dog | undefined;
144
+ *
145
+ * @DataSource
146
+ * static readonly default: IncludeTree<Person> = {
147
+ * dog: {}, // join Dog table when querying Person with `default` data source
148
+ * };
149
+ * }
150
+ *
151
+ * // When queried via the ORM or client API:
152
+ * const orm = Orm.fromD1(env.db);
153
+ * const people = await orm.list(Person, Person.default);
154
+ *
155
+ * // => Person { id: 1, dogId: 2, dog: { id: 2, name: "Fido" } }[]
156
+ * ```
157
+ */
16
158
  export const DataSource = () => { };
17
- export const OneToMany = (_) => () => { };
18
- export const OneToOne = (_) => () => { };
19
- export const ManyToMany = (_) => () => { };
20
- export const ForeignKey = (_) => () => { };
159
+ /**
160
+ * Declares a one-to-many relationship between models.
161
+ *
162
+ * The argument is the foreign key property name on the
163
+ * related model.
164
+ *
165
+ * Example:
166
+ * ```ts
167
+ * @OneToMany("personId")
168
+ * dogs: Dog[];
169
+ * ```
170
+ */
171
+ export const OneToMany = (_foreignKeyColumn) => () => { };
172
+ /**
173
+ * Declares a one-to-one relationship between models.
174
+ *
175
+ * The argument is the foreign key property name that links
176
+ * the two tables.
177
+ *
178
+ * Example:
179
+ * ```ts
180
+ * @OneToOne("dogId")
181
+ * dog: Dog | undefined;
182
+ * ```
183
+ */
184
+ export const OneToOne = (_foreignKeyColumn) => () => { };
185
+ /**
186
+ * Declares a many-to-many relationship between models.
187
+ *
188
+ * The argument is a unique identifier for the generated
189
+ * junction table used to connect the two entities.
190
+ *
191
+ * Example:
192
+ * ```ts
193
+ * @ManyToMany("StudentsCourses")
194
+ * courses: Course[];
195
+ * ```
196
+ */
197
+ export const ManyToMany = (_uniqueId) => () => { };
198
+ /**
199
+ * Declares a foreign key relationship between models.
200
+ * Directly translates to a SQLite foreign key.
201
+ *
202
+ * The argument must reference either a model class or the
203
+ * name of a model class as a string. The property type must
204
+ * match the target model’s primary key type.
205
+ *
206
+ * Example:
207
+ * ```ts
208
+ * @ForeignKey(Dog)
209
+ * dogId: number;
210
+ * ```
211
+ */
212
+ export const ForeignKey = (_Model) => () => { };
213
+ /**
214
+ * Marks a method parameter for dependency injection.
215
+ *
216
+ * Injected parameters can receive environment bindings,
217
+ * middleware-provided objects, or other registered values.
218
+ *
219
+ * Note that injected parameters will not appear in the client
220
+ * API.
221
+ *
222
+ * Example:
223
+ * ```ts
224
+ * @POST
225
+ * async neigh(@Inject env: WranglerEnv) {
226
+ * return `i am ${this.name}`;
227
+ * }
228
+ * ```
229
+ */
21
230
  export const Inject = () => { };
231
+ /**
232
+ * Enables automatic CRUD method generation for a model.
233
+ *
234
+ * The argument is a list of CRUD operation kinds
235
+ * (e.g. `"SAVE"`, `"GET"`, `"LIST"`) to generate for the model.
236
+ *
237
+ * Cloesce will emit corresponding backend methods and frontend
238
+ * client bindings automatically, removing the need to manually
239
+ * define common API operations.
240
+ *
241
+ * CRUD Operations:
242
+ * - **"SAVE"** — Performs an *upsert* (insert, update, or both) for a model instance.
243
+ * - **"GET"** — Retrieves a single record by its primary key, optionally using a `DataSource`.
244
+ * - **"LIST"** — Retrieves all records for the model, using the specified `DataSource`.
245
+ *
246
+ * The generated methods are static, exposed through both the backend
247
+ * and the frontend client API.
248
+ *
249
+ * Example:
250
+ * ```ts
251
+ * @CRUD(["SAVE", "GET", "LIST"])
252
+ * @D1
253
+ * export class CrudHaver {
254
+ * @PrimaryKey id: number;
255
+ * name: string;
256
+ * }
257
+ * ```
258
+ */
22
259
  export const CRUD = (_kinds) => () => { };
23
260
  /**
24
- * ORM functions which use metadata to translate arguments to valid SQL queries.
261
+ * Exposes the ORM primitives Cloesce uses to interact with D1 databases.
25
262
  */
26
263
  export class Orm {
27
264
  db;
@@ -42,41 +279,20 @@ export class Orm {
42
279
  * @param ctor The model constructor
43
280
  * @param records D1 Result records
44
281
  * @param includeTree Include tree to define the relationships to join.
45
- * @returns
46
- */
47
- static fromSql(ctor, records, includeTree) {
48
- return fromSql(ctor, records, includeTree);
49
- }
50
- /**
51
- * Returns a SQL query to insert a model into the database. Uses an IncludeTree as a guide for
52
- * foreign key relationships, only inserting the explicitly stated pattern in the tree.
53
- *
54
- * TODO: We should be able to leave primary keys and foreign keys undefined, with
55
- * primary keys being auto incremented and foreign keys being assumed by navigation property
56
- * context.
57
- *
58
- * @param ctor A model constructor.
59
- * @param newModel The new model to insert.
60
- * @param includeTree An include tree describing which foreign keys to join.
61
- * @returns Either an error string, or the insert query string.
62
282
  */
63
- static upsertQuery(ctor, newModel, includeTree) {
64
- const { wasm } = RuntimeContainer.get();
65
- const args = [
66
- WasmResource.fromString(ctor.name, wasm),
67
- WasmResource.fromString(JSON.stringify(newModel), wasm),
68
- WasmResource.fromString(JSON.stringify(includeTree), wasm),
69
- ];
70
- return invokeOrmWasm(wasm.upsert_model, args, wasm);
283
+ static mapSql(ctor, records, includeTree = null) {
284
+ return mapSql(ctor, records, includeTree);
71
285
  }
72
286
  /**
73
287
  * Executes an "upsert" query, adding or augmenting a model in the database.
288
+ *
74
289
  * If a model's primary key is not defined in `newModel`, the query is assumed to be an insert.
290
+ *
75
291
  * If a model's primary key _is_ defined, but some attributes are missing, the query is assumed to be an update.
292
+ *
76
293
  * Finally, if the primary key is defined, but all attributes are included, a SQLite upsert will be performed.
77
294
  *
78
- * Capable of inferring foreign keys from the surrounding context of the model. A missing primary key is allowed
79
- * only if the primary key is an integer, in which case it will be auto incremented and assigned.
295
+ * In any other case, an error string will be returned.
80
296
  *
81
297
  * ### Inserting a new Model
82
298
  * ```ts
@@ -110,92 +326,177 @@ export class Orm {
110
326
  * @param includeTree An include tree describing which foreign keys to join.
111
327
  * @returns An error string, or the primary key of the inserted model.
112
328
  */
113
- async upsert(ctor, newModel, includeTree) {
114
- let upsertQueryRes = Orm.upsertQuery(ctor, newModel, includeTree);
115
- if (!upsertQueryRes.ok) {
329
+ async upsert(ctor, newModel, includeTree = null) {
330
+ const { wasm } = RuntimeContainer.get();
331
+ const args = [
332
+ WasmResource.fromString(ctor.name, wasm),
333
+ WasmResource.fromString(JSON.stringify(newModel), wasm),
334
+ WasmResource.fromString(JSON.stringify(includeTree), wasm),
335
+ ];
336
+ const upsertQueryRes = invokeOrmWasm(wasm.upsert_model, args, wasm);
337
+ if (upsertQueryRes.isLeft()) {
116
338
  return upsertQueryRes;
117
339
  }
118
- // Split the query into individual statements.
119
- const statements = upsertQueryRes.value
120
- .split(";")
121
- .map((s) => s.trim())
122
- .filter((s) => s.length > 0);
340
+ const statements = JSON.parse(upsertQueryRes.unwrap());
123
341
  // One of these statements is a "SELECT", which is the root model id stmt.
124
342
  let selectIndex;
125
343
  for (let i = statements.length - 1; i >= 0; i--) {
126
- if (/^SELECT/i.test(statements[i])) {
344
+ if (/^SELECT/i.test(statements[i].query)) {
127
345
  selectIndex = i;
128
346
  break;
129
347
  }
130
348
  }
131
349
  // Execute all statements in a batch.
132
- const batchRes = await this.db.batch(statements.map((s) => this.db.prepare(s)));
350
+ const batchRes = await this.db.batch(statements.map((s) => this.db.prepare(s.query).bind(...s.values)));
133
351
  if (!batchRes.every((r) => r.success)) {
134
352
  const failed = batchRes.find((r) => !r.success);
135
- return left(failed?.error ?? "D1 batch failed, but no error was returned.");
353
+ return Either.left(failed?.error ?? "D1 batch failed, but no error was returned.");
136
354
  }
137
355
  // Return the result of the SELECT statement
138
356
  const selectResult = batchRes[selectIndex].results[0];
139
- return right(selectResult.id);
357
+ return Either.right(selectResult.id);
140
358
  }
141
359
  /**
142
- * Returns a query of the form `SELECT * FROM [Model.DataSource]`
360
+ * Returns a select query, creating a CTE view for the model using the provided include tree.
361
+ *
362
+ * @param ctor The model constructor.
363
+ * @param includeTree An include tree describing which related models to join.
364
+ * @param from An optional custom `FROM` clause to use instead of the base table.
365
+ * @param tagCte An optional CTE name to tag the query with. Defaults to "Model.view".
366
+ *
367
+ * ### Example:
368
+ * ```ts
369
+ * // Using a data source
370
+ * const query = Orm.listQuery(Person, "default");
371
+ *
372
+ * // Using a custom from statement
373
+ * const query = Orm.listQuery(Person, null, "SELECT * FROM Person WHERE age > 18");
374
+ * ```
375
+ *
376
+ * ### Example SQL output:
377
+ * ```sql
378
+ * WITH Person_view AS (
379
+ * SELECT
380
+ * "Person"."id" AS "id",
381
+ * ...
382
+ * FROM "Person"
383
+ * LEFT JOIN ...
384
+ * )
385
+ * SELECT * FROM Person_view
386
+ * ```
143
387
  */
144
- static listQuery(ctor, includeTree) {
145
- if (includeTree) {
146
- return `SELECT * FROM [${ctor.name}.${includeTree.toString()}]`;
388
+ static listQuery(ctor, opts) {
389
+ const { wasm } = RuntimeContainer.get();
390
+ const args = [
391
+ WasmResource.fromString(ctor.name, wasm),
392
+ WasmResource.fromString(JSON.stringify(opts.includeTree ?? null), wasm),
393
+ WasmResource.fromString(JSON.stringify(opts.tagCte ?? null), wasm),
394
+ WasmResource.fromString(JSON.stringify(opts.from ?? null), wasm),
395
+ ];
396
+ const res = invokeOrmWasm(wasm.list_models, args, wasm);
397
+ if (res.isLeft()) {
398
+ throw new Error(`Error invoking the Cloesce WASM Binary: ${res.value}`);
147
399
  }
148
- return `SELECT * FROM [${ctor.name}]`;
400
+ return res.unwrap();
149
401
  }
150
402
  /**
151
- * Returns a query of the form `SELECT * FROM [Model.DataSource] WHERE [Model.PrimaryKey] = ?`.
152
- * Requires the id parameter to be bound (use db.prepare().bind)
403
+ * Returns a select query for a single model by primary key, creating a CTE view using the provided include tree.
404
+ *
405
+ * @param ctor The model constructor.
406
+ * @param includeTree An include tree describing which related models to join.
407
+ *
408
+ * ### Example:
409
+ * ```ts
410
+ * // Using a data source
411
+ * const query = Orm.getQuery(Person, "default");
412
+ * ```
413
+ *
414
+ * ### Example SQL output:
415
+ *
416
+ * ```sql
417
+ * WITH Person_view AS (
418
+ * SELECT
419
+ * "Person"."id" AS "id",
420
+ * ...
421
+ * FROM "Person"
422
+ * LEFT JOIN ...
423
+ * )
424
+ * SELECT * FROM Person_view WHERE [Person].[id] = ?
425
+ * ```
153
426
  */
154
427
  static getQuery(ctor, includeTree) {
155
428
  const { ast } = RuntimeContainer.get();
156
- if (includeTree) {
157
- return `${this.listQuery(ctor, includeTree)} WHERE [${ctor.name}.${ast.models[ctor.name].primary_key.name}] = ?`;
158
- }
159
- return `${this.listQuery(ctor, includeTree)} WHERE [${ast.models[ctor.name].primary_key.name}] = ?`;
429
+ return `${this.listQuery(ctor, { includeTree })} WHERE [${ast.models[ctor.name].primary_key.name}] = ?`;
160
430
  }
161
431
  /**
162
- * Executes a query of the form `SELECT * FROM [Model.DataSource]`, returning all results
163
- * as instantiated models.
432
+ * Retrieves all instances of a model from the database.
433
+ * @param ctor The model constructor.
434
+ * @param includeTree An include tree describing which related models to join.
435
+ * @param from An optional custom `FROM` clause to use instead of the base table.
436
+ * @returns Either an error string, or an array of model instances.
437
+ *
438
+ * ### Example:
439
+ * ```ts
440
+ * const orm = Orm.fromD1(env.db);
441
+ * const horses = await orm.list(Horse, Horse.default);
442
+ * ```
443
+ *
444
+ * ### Example with custom from:
445
+ * ```ts
446
+ * const orm = Orm.fromD1(env.db);
447
+ * const adultHorses = await orm.list(Horse, Horse.default, "SELECT * FROM Horse ORDER BY age DESC LIMIT 10");
448
+ * ```
449
+ *
450
+ * =>
451
+ *
452
+ * ```sql
453
+ * SELECT
454
+ * "Horse"."id" AS "id",
455
+ * ...
456
+ * FROM (SELECT * FROM Horse ORDER BY age DESC LIMIT 10)
457
+ * LEFT JOIN ...
458
+ * ```
459
+ *
164
460
  */
165
- async list(ctor, includeTreeKey) {
166
- const q = Orm.listQuery(ctor, includeTreeKey);
167
- const res = await this.db.prepare(q).run();
168
- if (!res.success) {
169
- return left(res.error ?? "D1 failed but no error was returned.");
461
+ async list(ctor, opts) {
462
+ const sql = Orm.listQuery(ctor, opts);
463
+ const stmt = this.db.prepare(sql);
464
+ const records = await stmt.all();
465
+ if (!records.success) {
466
+ return Either.left(records.error ?? "D1 query failed, but no error was returned.");
170
467
  }
171
- const { ast } = RuntimeContainer.get();
172
- const includeTree = includeTreeKey === null
173
- ? null
174
- : ast.models[ctor.name].data_sources[includeTreeKey.toString()].tree;
175
- const fromSqlRes = fromSql(ctor, res.results, includeTree);
176
- if (!fromSqlRes.ok) {
177
- return fromSqlRes;
468
+ const mapRes = Orm.mapSql(ctor, records.results, opts.includeTree ?? null);
469
+ if (mapRes.isLeft()) {
470
+ return Either.left(mapRes.value);
178
471
  }
179
- return right(fromSqlRes.value);
472
+ return Either.right(mapRes.value);
180
473
  }
181
474
  /**
182
- * Executes a query of the form `SELECT * FROM [Model.DataSource] WHERE [Model.PrimaryKey] = ?`
183
- * returning all results as instantiated models.
475
+ * Retrieves a single model by primary key.
476
+ * @param ctor The model constructor.
477
+ * @param id The primary key value.
478
+ * @param includeTree An include tree describing which related models to join.
479
+ * @returns Either an error string, or the model instance (null if not found).
480
+ *
481
+ * ### Example:
482
+ * ```ts
483
+ * const orm = Orm.fromD1(env.db);
484
+ * const horse = await orm.get(Horse, 1, Horse.default);
485
+ * ```
184
486
  */
185
- async get(ctor, id, includeTreeKey) {
186
- const q = Orm.getQuery(ctor, includeTreeKey);
187
- const res = await this.db.prepare(q).bind(id).run();
188
- if (!res.success) {
189
- return left(res.error ?? "D1 failed but no error was returned.");
487
+ async get(ctor, id, includeTree) {
488
+ const sql = Orm.getQuery(ctor, includeTree);
489
+ const record = await this.db.prepare(sql).bind(id).run();
490
+ if (!record.success) {
491
+ return Either.left(record.error ?? "D1 query failed, but no error was returned.");
190
492
  }
191
- const { ast } = RuntimeContainer.get();
192
- const includeTree = includeTreeKey === null
193
- ? null
194
- : ast.models[ctor.name].data_sources[includeTreeKey.toString()].tree;
195
- const fromSqlRes = fromSql(ctor, res.results, includeTree);
196
- if (!fromSqlRes.ok) {
197
- return fromSqlRes;
493
+ if (record.results.length === 0) {
494
+ return Either.right(null);
495
+ }
496
+ const mapRes = Orm.mapSql(ctor, record.results, includeTree);
497
+ if (mapRes.isLeft()) {
498
+ return Either.left(mapRes.value);
198
499
  }
199
- return right(fromSqlRes.value[0]);
500
+ return Either.right(mapRes.value[0]);
200
501
  }
201
502
  }
@@ -1,4 +1,5 @@
1
- export type { HttpResult, Either, DeepPartial } from "../common.js";
1
+ export type { DeepPartial } from "../common.js";
2
+ export { HttpResult, Either } from "../common.js";
2
3
  export declare function instantiateObjectArray<T extends object>(data: any, ctor: {
3
4
  new (): T;
4
5
  }): T[];
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/ui/client.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGpE,wBAAgB,sBAAsB,CAAC,CAAC,SAAS,MAAM,EACrD,IAAI,EAAE,GAAG,EACT,IAAI,EAAE;IAAE,QAAQ,CAAC,CAAA;CAAE,GAClB,CAAC,EAAE,CAKL"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/ui/client.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAGlD,wBAAgB,sBAAsB,CAAC,CAAC,SAAS,MAAM,EACrD,IAAI,EAAE,GAAG,EACT,IAAI,EAAE;IAAE,QAAQ,CAAC,CAAA;CAAE,GAClB,CAAC,EAAE,CAKL"}
package/dist/ui/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { HttpResult, Either } from "../common.js";
1
2
  // Helpers
2
3
  export function instantiateObjectArray(data, ctor) {
3
4
  if (Array.isArray(data)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloesce",
3
- "version": "0.0.4-unstable.1",
3
+ "version": "0.0.4-unstable.10",
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",