cloesce 0.0.4-unstable.5 → 0.0.4-unstable.6

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 ADDED
@@ -0,0 +1,619 @@
1
+ # cloesce (unstable, `v0.0.4`)
2
+
3
+ Cloesce is a full stack compiler for the Cloudflare developer platform, allowing class definitions in high level languages to serve as a metadata basis to create a database schema, backend REST API, frontend API client, and Cloudflare infrastructure (as of v0.0.4, D1 + Workers).
4
+
5
+ Cloesce is working towards a stable alpha MVP (v0.1.0), with the general milestones being [here](https://cloesce.pages.dev/schreiber/v0.1.0_milestones/).
6
+
7
+ Internal documentation going over design decisions and general thoughts for each milestone can be found [here](https://cloesce.pages.dev/).
8
+
9
+ # Documentation
10
+
11
+ ## Getting Started
12
+
13
+ `v0.0.4` supports only Typescript-to-Typescript projects. An example project is shown [here](https://github.com/bens-schreiber/cloesce/tree/main/examples).
14
+
15
+ ### 1) NPM
16
+
17
+ Create an NPM project and install cloesce
18
+
19
+ ```sh
20
+ npm i cloesce@0.0.4-unstable.6
21
+ ```
22
+
23
+ ### 2) TypeScript
24
+
25
+ Create a `tsconfig.json` with the following values:
26
+
27
+ ```json
28
+ {
29
+ "compilerOptions": {
30
+ // ...
31
+ "resolveJsonModule": true,
32
+ "strict": true,
33
+ "strictPropertyInitialization": false,
34
+ "experimentalDecorators": true,
35
+ "emitDecoratorMetadata": true
36
+ },
37
+ "include": ["<your_src_dir>/**/*.ts", ".generated/*.ts"]
38
+ }
39
+ ```
40
+
41
+ ### 3) Cloesce Config
42
+
43
+ Create a `cloesce.config.json` with your desired configuration:
44
+
45
+ ```json
46
+ {
47
+ "source": "./src",
48
+ "workersUrl": "http://localhost:5000/api",
49
+ "clientUrl": "http://localhost:5173/api"
50
+ }
51
+ ```
52
+
53
+ ### 4) Vite
54
+
55
+ To prevent CORS issues, a Vite proxy can be used for the frontend:
56
+
57
+ ```ts
58
+ import { defineConfig } from "vite";
59
+
60
+ export default defineConfig({
61
+ server: {
62
+ port: 5173,
63
+ proxy: {
64
+ "/api": {
65
+ target: "http://localhost:5000",
66
+ changeOrigin: true,
67
+ },
68
+ },
69
+ },
70
+ });
71
+ ```
72
+
73
+ ### 5) Wrangler Config
74
+
75
+ Cloesce will generate any missing `wrangler.toml` values (or the file if missing). A minimal `wrangler.toml` looks like this:
76
+
77
+ ```toml
78
+ compatibility_date = "2025-10-02"
79
+ main = ".generated/workers.ts"
80
+ name = "example"
81
+
82
+ [[d1_databases]]
83
+ binding = "db"
84
+ database_id = "..."
85
+ database_name = "example"
86
+ ```
87
+
88
+ ## Cloesce Models
89
+
90
+ A model is a type which represents:
91
+
92
+ - a database table,
93
+ - REST API
94
+ - Client API
95
+ - Cloudflare infrastructure (D1 + Workers)
96
+
97
+ Suprisingly, it's pretty compact. A basic model looks like this:
98
+
99
+ ```ts
100
+ // horse.cloesce.ts
101
+ import { D1, GET, POST, PrimaryKey } from "cloesce/backend";
102
+
103
+ @D1
104
+ export class Horse {
105
+ @PrimaryKey
106
+ id: number;
107
+
108
+ name: string | null;
109
+
110
+ @POST
111
+ neigh(): string {
112
+ return `i am ${this.name}, this is my horse noise`;
113
+ }
114
+ }
115
+ ```
116
+
117
+ - `@D1` denotes that this is a SQL Table
118
+ - `@PrimaryKey` sets the SQL primary key. All models require a primary key.
119
+ - `@POST` reveals the method as an API endpoint with the `POST` HTTP Verb.
120
+ - All Cloesce models need to be under a `.cloesce.ts` file.
121
+
122
+ To compile this model into a working full stack application, Cloesce must undergo both **compilation** and **migrations**.
123
+
124
+ Compilation is the process of extracting the metadata language that powers Cloesce, ensuring it is a valid program, and then producing code to orchestrate the program across different domains (database, backend, frontend, cloud).
125
+
126
+ Migrations utilize the history of validated metadata to create SQL code, translating the evolution of your models.
127
+
128
+ ### Compiling
129
+
130
+ - `npx cloesce compile`
131
+ - `npx cloesce migrate <name>`.
132
+
133
+ After running the above commands, you will have a full project capable of being ran with Wrangler:
134
+
135
+ ```sh
136
+ # Apply the generated migrations
137
+ npx wrangler d1 migrations apply <db-name>
138
+
139
+ # Run the backend
140
+ npx wrangler dev
141
+ ```
142
+
143
+ ### Compiled Artifacts
144
+
145
+ #### `.generated/`
146
+
147
+ These values should not be committed to git, as they depend on the file system of the machine running it.
148
+
149
+ - `client.ts` is an importable API with all of your backend types and endpoints
150
+ - `workers.ts` is the workers entrypoint.
151
+ - `cidl.json` is the working metadata for the project
152
+
153
+ #### `migrations`
154
+
155
+ After running `npx cloesce migrate <name>`, a new migration will be created in the `migrations/` folder. For example, after creating a migration called `Initial`, you will see:
156
+
157
+ - `<date>_Initial.json` contains all model information necessary from SQL
158
+ - `<date>_Initial.sql` contains the acual SQL migrations. In this early version of Cloesce, it's important to check migrations every time.
159
+
160
+ #### Supported Column Types
161
+
162
+ Model columns must directly map to SQLite columns. The supported TypeScript types are:
163
+
164
+ - `number` => `Real` not null
165
+ - `string` => `Text` not null
166
+ - `boolean` and `Boolean` => `Integer` not null
167
+ - `Integer` => `Integer` not null
168
+ - `Date` => `Text` (ISO formatted) not null
169
+ - `N | null` => `N` (nullable)
170
+
171
+ Blob types will be added in v0.0.5 when R2 support is added.
172
+
173
+ ## Features
174
+
175
+ ### Wrangler Environment
176
+
177
+ In order to interact with your database, you will need to define a WranglerEnv
178
+
179
+ ```ts
180
+ import { WranglerEnv } from "cloesce/backend";
181
+
182
+ @WranglerEnv
183
+ export class MyEnv {
184
+ db: D1Database; // only one DB is supported for now-- make sure it matches the name in `wrangler.toml`
185
+
186
+ // you can also define values in the toml under [[vars]]
187
+ motd: string;
188
+ }
189
+ ```
190
+
191
+ Your WranglerEnv can then be injected into any model method using the `@Inject` decorator:
192
+
193
+ ```ts
194
+ @D1
195
+ export class Horse {
196
+ @PrimaryKey
197
+ id: number;
198
+
199
+ @POST
200
+ async neigh(@Inject env: MyEnv): Promise<string> {
201
+ await env.db.prepare(...);
202
+
203
+ return `i am ${this.name}, this is my horse noise`;
204
+ }
205
+ }
206
+ ```
207
+
208
+ ### Foreign Key Column
209
+
210
+ Reference another model via a foreign key using the `@ForeignKey` decorator:
211
+
212
+ ```ts
213
+ @D1
214
+ export class Dog {
215
+ @PrimaryKey
216
+ id: number;
217
+ }
218
+
219
+ @D1
220
+ export class Person {
221
+ @PrimaryKey
222
+ id: number;
223
+
224
+ @ForeignKey(Dog)
225
+ dogId: number;
226
+ }
227
+ ```
228
+
229
+ ### One to One
230
+
231
+ Cloesce allows you to relate models via `1:1` relationships using the `@OneToOne` decorator. It requires that a foreign key already exists on the model.
232
+
233
+ ```ts
234
+ @D1
235
+ export class Dog {
236
+ @PrimaryKey
237
+ id: number;
238
+ }
239
+
240
+ @D1
241
+ export class Person {
242
+ @PrimaryKey
243
+ id: number;
244
+
245
+ @ForeignKey(Dog)
246
+ dogId: number;
247
+
248
+ @OneToOne("dogId") // references Person.dogId
249
+ dog: Dog | undefined; // This value is a "navigation property", which may or may not exist at runtime
250
+ }
251
+ ```
252
+
253
+ In `v0.0.4`, there are no defaults, only very explicit decisons. Because of that, navigation properties won't exist at runtime unless you tell them to. Cloesce does this via a `Data Source`, which describes the foreign key dependencies you wish to include. All scalar properties are included by default and cannot be excluded.
254
+
255
+ ```ts
256
+ @D1
257
+ export class Dog {
258
+ @PrimaryKey
259
+ id: number;
260
+ }
261
+
262
+ @D1
263
+ export class Person {
264
+ @PrimaryKey
265
+ id: number;
266
+
267
+ @ForeignKey(Dog)
268
+ dogId: number;
269
+
270
+ @OneToOne("dogId")
271
+ dog: Dog | undefined;
272
+
273
+ @DataSource
274
+ static readonly default: IncludeTree<Person> = {
275
+ dog: {}, // says: on model population, join Persons's Dog
276
+ };
277
+ }
278
+ ```
279
+
280
+ Data sources describe how foreign keys should be joined on model hydration (i.e. when invoking any instantiated method). They are composed of an `IncludeTree<T>`, a recursive type composed of the relationships you wish to include. All scalar properties are always included.
281
+
282
+ Note that `DataSourceOf` is added implicitly to all instantiated methods if no data source parameter is defined.
283
+
284
+ ### One to Many
285
+
286
+ Cloesce supports models with `1:M` relationships:
287
+
288
+ ```ts
289
+ @D1
290
+ export class Person {
291
+ @PrimaryKey
292
+ id: number;
293
+
294
+ @OneToMany("personId") // directly references the FK on Dog
295
+ dogs: Dog[];
296
+
297
+ @DataSource
298
+ static readonly default: IncludeTree<Person> = {
299
+ dogs: {
300
+ person: {
301
+ dogs: {
302
+ // essentially means: "When you get a person, get their dogs, and get all of those dog's Person, ..."
303
+ // we could go on as long as we want
304
+ },
305
+ },
306
+ },
307
+ };
308
+ }
309
+
310
+ @D1
311
+ export class Dog {
312
+ @PrimaryKey
313
+ id: number;
314
+
315
+ @ForeignKey(Person)
316
+ personId: number;
317
+
318
+ // optional navigation property, not needed.
319
+ @OneToOne("personId")
320
+ person: Person | undefined;
321
+ }
322
+ ```
323
+
324
+ ### Many to Many
325
+
326
+ ```ts
327
+ @D1
328
+ export class Student {
329
+ @PrimaryKey
330
+ id: number;
331
+
332
+ @ManyToMany("StudentsCourses") // unique ID for the generated junction table
333
+ courses: Course[];
334
+ }
335
+
336
+ @D1
337
+ export class Course {
338
+ @PrimaryKey
339
+ id: number;
340
+
341
+ @ManyToMany("StudentsCourses") // same unique id => same jct table.
342
+ students: Student[];
343
+ }
344
+ ```
345
+
346
+ ### DataSourceOf<T>
347
+
348
+ If it is important to determine what data source the frontend called the instantiated method with, the type `DataSourceOf<T>` allows explicit data source parameters:
349
+
350
+ ```ts
351
+ @D1
352
+ class Foo {
353
+ ...
354
+
355
+ @POST
356
+ bar(ds: DataSourceOf<Foo>) {
357
+ // ds = "DataSource1" | "DataSource2" | ... | "none"
358
+ }
359
+ }
360
+ ```
361
+
362
+ Data sources are implicitly added to all instantiated methods if no data source parameter is defined.
363
+
364
+ ### ORM Methods
365
+
366
+ Cloesce provides a suite of ORM methods for getting, listing, updating and inserting models.
367
+
368
+ #### Upsert
369
+
370
+ ```ts
371
+ @D1
372
+ class Horse {
373
+ // ...
374
+
375
+ @POST
376
+ static async post(@Inject { db }: Env, horse: Horse): Promise<Horse> {
377
+ const orm = Orm.fromD1(db);
378
+ await orm.upsert(Horse, horse, null);
379
+ return (await orm.get(Horse, horse.id, null)).value;
380
+ }
381
+ }
382
+ ```
383
+
384
+ #### List, Get
385
+
386
+ Both methods take an optional `IncludeTree<T>` parameter to specify what relationships in the generated CTE.
387
+
388
+ ```ts
389
+ @D1
390
+ export class Person {
391
+ @PrimaryKey
392
+ id: number;
393
+
394
+ @ForeignKey(Dog)
395
+ dogId: number;
396
+
397
+ @OneToOne("dogId")
398
+ dog: Dog | undefined;
399
+
400
+ @DataSource
401
+ static readonly default: IncludeTree<Person> = {
402
+ dog: {},
403
+ };
404
+ }
405
+ ```
406
+
407
+ Running `Orm.listQuery` with the data source `Person.default` would produce the CTE:
408
+
409
+ ```sql
410
+ WITH "Person_view" AS (
411
+ SELECT
412
+ "Person"."id" AS "id",
413
+ "Person"."dogId" AS "dogId",
414
+ "Dog"."id" AS "dog.id"
415
+ FROM
416
+ "Person"
417
+ LEFT JOIN
418
+ "Dog" ON "Person"."dogId" = "Dog"."id"
419
+ ) SELECT * FROM "Person_view"
420
+ ```
421
+
422
+ Example usages:
423
+
424
+ ```ts
425
+ @D1
426
+ class Horse {
427
+ // ...
428
+ @GET
429
+ static async get(@Inject { db }: Env, id: number): Promise<Horse> {
430
+ const orm = Orm.fromD1(db);
431
+ return (await orm.get(Horse, id, Horse.default)).value;
432
+ }
433
+
434
+ @GET
435
+ static async list(@Inject { db }: Env): Promise<Horse[]> {
436
+ const orm = Orm.fromD1(db);
437
+ return (await orm.list(Horse, {})).value;
438
+ }
439
+ }
440
+ ```
441
+
442
+ `list` takes an optional `from` parameter to modify the source of the list query. This is useful in filtering / limiting results.
443
+
444
+ ```ts
445
+ await orm.list(
446
+ Horse,
447
+ Horse.default,
448
+ "SELECT * FROM Horse ORDER BY name LIMIT 10"
449
+ );
450
+ ```
451
+
452
+ produces SQL
453
+
454
+ ```sql
455
+ WITH "Horse_view" AS (
456
+ SELECT
457
+ "Horse"."id" AS "id",
458
+ "Horse"."name" AS "name"
459
+ FROM
460
+ (SELECT * FROM Horse ORDER BY name LIMIT 10) as "Horse"
461
+ ) SELECT * FROM "Horse_view"
462
+ ```
463
+
464
+ ### CRUD Methods
465
+
466
+ Generic `GET, POST, PATCH` (and in a future version, DEL) boilerplate methods do not need to be copied around. Cloesce supports CRUD generation, a syntactic sugar that adds the methods to the compiler output.
467
+
468
+ The `SAVE` method is an `upsert`, meaning it both inserts and updates in the same query.
469
+
470
+ ```ts
471
+ @CRUD(["SAVE", "GET", "LIST"])
472
+ @D1
473
+ export class CrudHaver {
474
+ @PrimaryKey
475
+ id: number;
476
+ name: string;
477
+ }
478
+ ```
479
+
480
+ which will generate client API methods like:
481
+
482
+ ```ts
483
+ static async get(
484
+ id: number,
485
+ dataSource: "none" = "none",
486
+ ): Promise<HttpResult<CrudHaver>>
487
+ ```
488
+
489
+ ### Middleware
490
+
491
+ Cloesce supports middleware at the global level (before routing to a model+method), the model level (before hydration) and the method level (before hydration). Middleware also exposes read/write access to the dependency injection instance that all models use.
492
+
493
+ Middleware is capable of exiting from the Cloesce Router early with an HTTP Result.
494
+
495
+ An example of all levels of middleware is below. All middleware must be defined in the file `app.cloesce.ts`.
496
+
497
+ ```ts
498
+ @PlainOldObject
499
+ export class InjectedThing {
500
+ value: string;
501
+ }
502
+
503
+ @WranglerEnv
504
+ export class Env {
505
+ db: D1Database;
506
+ }
507
+
508
+ @D1
509
+ @CRUD(["POST"])
510
+ export class Model {
511
+ @PrimaryKey
512
+ id: number;
513
+
514
+ @GET
515
+ static blockedMethod() {}
516
+
517
+ @GET
518
+ static getInjectedThing(@Inject thing: InjectedThing): InjectedThing {
519
+ return thing;
520
+ }
521
+ }
522
+
523
+ // Middleware instance
524
+ const app: CloesceApp = new CloesceApp();
525
+
526
+ app.useGlobal((request: Request, env, ir) => {
527
+ if (request.method === "POST") {
528
+ return { ok: false, status: 401, message: "POST methods aren't allowed." };
529
+ }
530
+ });
531
+
532
+ app.useModel(Model, (request, env, ir) => {
533
+ ir.set(InjectedThing.name, {
534
+ value: "hello world",
535
+ });
536
+ });
537
+
538
+ app.useMethod(Model, "blockedMethod", (request, env, ir) => {
539
+ return { ok: false, status: 401, message: "Blocked method" };
540
+ });
541
+
542
+ // Exporting the instance is required
543
+ export default app;
544
+ ```
545
+
546
+ With this middleware, all POST methods will be blocked, and all methods for the model `Model` will be able to inject `InjectedThing`. Additionally, on the method level, `blockedMethod` will return a 401.
547
+
548
+ ### Plain Old Objects
549
+
550
+ Simple non-model objects can be returned and serialized from a model method:
551
+
552
+ ```ts
553
+ @PlainOldObject
554
+ export class CatStuff {
555
+ catFacts: string[],
556
+ catNames: string[],
557
+ }
558
+
559
+ @D1
560
+ export class Cat {
561
+ @PrimaryKey
562
+ id: number;
563
+
564
+ @GET
565
+ query(): CatStuff {
566
+ return {
567
+ catFacts: ["cats r cool"],
568
+ catNames: ["reginald"]
569
+ }
570
+ }
571
+ }
572
+ ```
573
+
574
+ ### HttpResult
575
+
576
+ Methods can return any kind of status code via the `HttpResult` wrapper:
577
+
578
+ ```ts
579
+ @D1
580
+ class Foo {
581
+ ...
582
+
583
+ @GET
584
+ async foo(): Promise<HttpResult<number>> {
585
+ return { ok: false, status: 500, message: "divided by 0"};
586
+ }
587
+ }
588
+ ```
589
+
590
+ # Testing the Compiler
591
+
592
+ ## Unit Tests
593
+
594
+ - `src/frontend/ts` run `npm test`
595
+ - `src/generator` run `cargo test`
596
+
597
+ ## Integration Tests
598
+
599
+ - Regression tests: `cargo run --bin test regression`
600
+
601
+ Optionally, pass `--check` if new snapshots should not be created.
602
+
603
+ To target a specific fixture, pass `--fixture folder_name`
604
+
605
+ To update integration snapshots, run:
606
+
607
+ - `cargo run --bin update`
608
+
609
+ To delete any generated snapshots run:
610
+
611
+ - `cargo run --bin update -- -d`
612
+
613
+ ## E2E
614
+
615
+ - `tests/e2e` run `npm test`
616
+
617
+ ## Code Formatting
618
+
619
+ - `cargo fmt`, `cargo clippy`, `npm run format:fix`
package/dist/cli.js CHANGED
@@ -82,6 +82,10 @@ const cmds = subcommands({
82
82
  short: "d",
83
83
  description: "Show debug output",
84
84
  }),
85
+ skipTsCheck: flag({
86
+ long: "skipTsCheck",
87
+ description: "Skip TypeScript compilation checks",
88
+ }),
85
89
  },
86
90
  handler: async (args) => {
87
91
  await extract({ ...args });
@@ -137,15 +141,15 @@ const cmds = subcommands({
137
141
  }),
138
142
  },
139
143
  });
140
- async function extract(opts) {
144
+ async function extract(args) {
141
145
  const root = process.cwd();
142
146
  const projectRoot = process.cwd();
143
- const config = loadCloesceConfig(projectRoot, opts.debug);
144
- const searchPaths = opts.inp ? [opts.inp] : (config.paths ?? [root]);
147
+ const config = loadCloesceConfig(projectRoot, args.debug);
148
+ const searchPaths = args.inp ? [args.inp] : (config.paths ?? [root]);
145
149
  const outputDir = config.outputDir ?? ".generated";
146
- const outPath = opts.out ?? path.join(outputDir, "cidl.pre.json");
147
- const truncate = opts.truncateSourcePaths ?? config.truncateSourcePaths ?? false;
148
- const cloesceProjectName = opts.projectName ??
150
+ const outPath = args.out ?? path.join(outputDir, "cidl.pre.json");
151
+ const truncate = args.truncateSourcePaths ?? config.truncateSourcePaths ?? false;
152
+ const cloesceProjectName = args.projectName ??
149
153
  config.projectName ??
150
154
  readPackageJsonProjectName(projectRoot);
151
155
  const project = new Project({
@@ -158,16 +162,25 @@ async function extract(opts) {
158
162
  if (fileCount === 0) {
159
163
  new ExtractorError(ExtractorErrorCode.MissingFile);
160
164
  }
161
- if (opts.debug)
165
+ if (args.debug)
162
166
  console.log(`Found ${fileCount} .cloesce.ts files`);
167
+ // Run typescript compiler checks to before extraction
168
+ if (!args.skipTsCheck) {
169
+ const diagnostics = project.getPreEmitDiagnostics();
170
+ if (diagnostics.length > 0) {
171
+ console.error("TypeScript errors detected in provided files:");
172
+ console.error(project.formatDiagnosticsWithColorAndContext(diagnostics));
173
+ process.exit(1);
174
+ }
175
+ }
163
176
  try {
164
- const extractor = new CidlExtractor(cloesceProjectName, "v0.0.3");
177
+ const extractor = new CidlExtractor(cloesceProjectName, "v0.0.4");
165
178
  const result = extractor.extract(project);
166
- if (!result.ok) {
179
+ if (result.isLeft()) {
167
180
  console.error(formatErr(result.value));
168
181
  process.exit(1);
169
182
  }
170
- let ast = result.value;
183
+ let ast = result.unwrap();
171
184
  if (truncate) {
172
185
  ast.wrangler_env.source_path =
173
186
  "./" + path.basename(ast.wrangler_env.source_path);