cloesce 0.0.4-unstable.3 → 0.0.4-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.
package/README.md DELETED
@@ -1,648 +0,0 @@
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 `v0.0.4`
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
- # check https://www.npmjs.com/package/cloesce/v/0.0.4-unstable.0?activeTab=versions for the most recent patch
21
- npm i cloesce@0.0.4-unstable.2
22
- ```
23
-
24
- 2. TypeScript
25
-
26
- - Create a `tsconfig.json` with the following values:
27
-
28
- ```json
29
- {
30
- "compilerOptions": {
31
- // ...
32
- "resolveJsonModule": true,
33
- "strict": true,
34
- "strictPropertyInitialization": false,
35
- "experimentalDecorators": true,
36
- "emitDecoratorMetadata": true
37
- },
38
- "include": ["<your_src_dir>/**/*.ts", ".generated/*.ts"]
39
- }
40
- ```
41
-
42
- 3. Cloesce Config
43
-
44
- - Create a `cloesce.config.json` with the following keys:
45
-
46
- ```json
47
- {
48
- "source": "./src",
49
- "workersUrl": "http://localhost:5000/api",
50
- "clientUrl": "http://localhost:5173/api"
51
- }
52
- ```
53
-
54
- 4. Vite
55
-
56
- To prevent CORS issues, a Vite proxy can be used for the frontend:
57
-
58
- ```ts
59
- import { defineConfig } from "vite";
60
-
61
- export default defineConfig({
62
- server: {
63
- port: 5173,
64
- proxy: {
65
- "/api": {
66
- target: "http://localhost:5000",
67
- changeOrigin: true,
68
- },
69
- },
70
- },
71
- });
72
- ```
73
-
74
- 5. Wrangler Config
75
-
76
- - `v0.0.4` will generate the required areas of your wrangler config. A full config looks like this:
77
-
78
- ```toml
79
- compatibility_date = "2025-10-02"
80
- main = ".generated/workers.ts"
81
- name = "example"
82
-
83
- [[d1_databases]]
84
- binding = "db"
85
- database_id = "..."
86
- database_name = "example"
87
- ```
88
-
89
- ## A Simple Model
90
-
91
- A model is a type which represents:
92
-
93
- - a database table,
94
- - database views
95
- - REST API
96
- - Client API
97
- - Cloudflare infrastructure (D1 + Workers)
98
-
99
- Suprisingly, it's pretty compact. A basic model looks like this:
100
-
101
- ```ts
102
- // horse.cloesce.ts
103
- import { D1, GET, POST, PrimaryKey } from "cloesce/backend";
104
-
105
- @D1
106
- export class Horse {
107
- @PrimaryKey
108
- id: number;
109
-
110
- name: string | null;
111
-
112
- @POST
113
- neigh(): string {
114
- return `i am ${this.name}, this is my horse noise`;
115
- }
116
- }
117
- ```
118
-
119
- - `@D1` denotes that this is a SQL Table
120
- - `@PrimaryKey` sets the SQL primary key. All models require a primary key.
121
- - `@POST` reveals the method as an API endpoint with the `POST` HTTP Verb.
122
- - All Cloesce models need to be under a `.cloesce.ts` file.
123
-
124
- To compile this model into a working full stack application, Cloesce must undergo both **compilation** and **migrations**. 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). Migrations utilize the history of validated metadata to create SQL code, translating the evolution of your models.
125
-
126
- To compile, run `npx cloesce compile`.
127
-
128
- To create a migration, run `npx cloesce migrate <name>`.
129
-
130
- After running the above commands, you will have a full project capable of being ran with:
131
-
132
- ```sh
133
- # Apply the generated migrations
134
- npx wrangler d1 migrations apply <db-name>
135
-
136
- # Run the backend
137
- npx wrangler dev
138
- ```
139
-
140
- Note the output in the `.generated/` dir. These values should not be committed to git, as they depend on the file system of the machine running it.
141
-
142
- - `client.ts` is an importable API with all of your backend types and endpoints
143
- - `workers.ts` is the workers entrypoint.
144
- - `cidl.json` is the working metadata for the project
145
-
146
- Note the output in `migrations`, ex after running `npx cloesce migrate Initial`
147
-
148
- - `<date>_Initial.json` contains all model information necessary from SQL
149
- - `<date>_Initial.sql` contains the acual SQL migrations. In this early version of Cloesce, it's important to check migrations every time.
150
-
151
- #### Supported Column Types
152
-
153
- Model columns must directly map to SQLite columns. The supported TypeScript types are:
154
-
155
- - `number` => `Real` not null
156
- - `string` => `Text` not null
157
- - `boolean` and `Boolean` => `Integer` not null
158
- - `Integer` => `Integer` not null
159
- - `Date` => `Text` (ISO formatted) not null
160
- - `N | null` => `N` (nullable)
161
-
162
- Blob types will be added in v0.0.5 when R2 support is added.
163
-
164
- ## Features
165
-
166
- ### Wrangler Environment
167
-
168
- In order to interact with your database, you will need to define a WranglerEnv
169
-
170
- ```ts
171
- import { WranglerEnv } from "cloesce/backend";
172
-
173
- @WranglerEnv
174
- export class Env {
175
- db: D1Database; // only one DB is supported for now-- make sure it matches the name in `wrangler.toml`
176
-
177
- // you can also define values in the toml under [[vars]]
178
- motd: string;
179
- }
180
- ```
181
-
182
- The wrangler environment is dependency injected into your method calls:
183
-
184
- ```ts
185
- @D1
186
- export class Horse {
187
- @PrimaryKey
188
- id: number;
189
-
190
- @POST
191
- async neigh(@Inject env: WranglerEnv): Promise<string> {
192
- await env.db.prepare(...);
193
-
194
- return `i am ${this.name}, this is my horse noise`;
195
- }
196
- }
197
- ```
198
-
199
- ### Foreign Keys, One to One, Data Sources
200
-
201
- Complex model relationships are permitted via the `@ForeignKey`, `@OneToOne / @OneToMany @ManyToMany` and `@DataSource` decorators.
202
- Foreign keys are scalar attributes which must reference some other model's primary key:
203
-
204
- ```ts
205
- @D1
206
- export class Dog {
207
- @PrimaryKey
208
- id: number;
209
- }
210
-
211
- @D1
212
- export class Person {
213
- @PrimaryKey
214
- id: number;
215
-
216
- @ForeignKey(Dog)
217
- dogId: number;
218
- }
219
- ```
220
-
221
- This representation is true to the underlying SQL table: `Person` has a column `dogId` which is a foreign key to `Dog`. Cloesce allows you to actually join these tables together in your model representation:
222
-
223
- ```ts
224
- @D1
225
- export class Dog {
226
- @PrimaryKey
227
- id: number;
228
- }
229
-
230
- @D1
231
- export class Person {
232
- @PrimaryKey
233
- id: number;
234
-
235
- @ForeignKey(Dog)
236
- dogId: number;
237
-
238
- @OneToOne("dogId") // references Person.dogId
239
- dog: Dog | undefined; // This value is a "navigation property", which may or may not exist at runtime
240
- }
241
- ```
242
-
243
- 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 `DataSource`, which describes the foreign key dependencies you wish to include. All scalar properties are included by default and cannot be excluded.
244
-
245
- ```ts
246
- @D1
247
- export class Dog {
248
- @PrimaryKey
249
- id: number;
250
- }
251
-
252
- @D1
253
- export class Person {
254
- @PrimaryKey
255
- id: number;
256
-
257
- @ForeignKey(Dog)
258
- dogId: number;
259
-
260
- @OneToOne("dogId")
261
- dog: Dog | undefined;
262
-
263
- @DataSource
264
- static readonly default: IncludeTree<Person> = {
265
- dog: {}, // says: on model population, join Persons's Dog
266
- };
267
- }
268
- ```
269
-
270
- Data sources are just SQL views and can be invoked in your queries. They are aliased in such a way that its similiar to object properties. The frontend chooses which datasource to use in it's API client (all instantiated methods have an implicit DataSource parameter). `null` is a valid option, meaning no joins will occur.
271
-
272
- ```ts
273
- @D1
274
- export class Person {
275
- @PrimaryKey
276
- id: number;
277
-
278
- @ForeignKey(Dog)
279
- dogId: number;
280
-
281
- @OneToOne("dogId")
282
- dog: Dog | undefined;
283
-
284
- @DataSource
285
- static readonly default: IncludeTree<Person> = {
286
- dog: {},
287
- };
288
-
289
- @GET
290
- static async get(id: number, @Inject env: WranglerEnv): Promise<Person> {
291
- let records = await env.db
292
- .prepare("SELECT * FROM [Person.default] WHERE [id] = ?") // Person.default is the SQL view generated from the IncludeTree
293
- .bind(id)
294
- .run();
295
-
296
- let persons = Orm.fromSql(Person, records.results, Person.default);
297
- return persons.value[0];
298
- }
299
- }
300
- ```
301
-
302
- Note that the `get` code can be simplified using CRUD methods or the ORM primitive `get`.
303
-
304
- #### View Aliasing
305
-
306
- The generated views will always be aliased so that they can be accessed in an object like notation. For example, given some `Horse` that has a relationship with `Like`:
307
-
308
- ```ts
309
- @D1
310
- export class Horse {
311
- @PrimaryKey
312
- id: Integer;
313
-
314
- name: string;
315
- bio: string | null;
316
-
317
- @OneToMany("horseId1")
318
- likes: Like[];
319
-
320
- @DataSource
321
- static readonly default: IncludeTree<Horse> = {
322
- likes: { horse2: {} },
323
- };
324
- }
325
-
326
- @D1
327
- export class Like {
328
- @PrimaryKey
329
- id: Integer;
330
-
331
- @ForeignKey(Horse)
332
- horseId1: Integer;
333
-
334
- @ForeignKey(Horse)
335
- horseId2: Integer;
336
-
337
- @OneToOne("horseId2")
338
- horse2: Horse | undefined;
339
- }
340
- ```
341
-
342
- If you wanted to find all horses that like one another, a valid SQL query using the `default` data source would look like:
343
-
344
- ```sql
345
- SELECT * FROM [Horse.default] as H1
346
- WHERE
347
- H1.[id] = ?
348
- AND EXISTS (
349
- SELECT 1
350
- FROM [Horse.default] AS H2
351
- WHERE H2.[id] = H1.[likes.horse2.id]
352
- AND H2.[likes.horse2.id] = H1.[id]
353
- );
354
- ```
355
-
356
- The actual generated view for `default` looks like:
357
-
358
- ```sql
359
- CREATE VIEW IF NOT EXISTS "Horse.default" AS
360
- SELECT
361
- "Horse"."id" AS "id",
362
- "Horse"."name" AS "name",
363
- "Horse"."bio" AS "bio",
364
- "Like"."id" AS "likes.id",
365
- "Like"."horseId1" AS "likes.horseId1",
366
- "Like"."horseId2" AS "likes.horseId2",
367
- "Horse1"."id" AS "likes.horse2.id",
368
- "Horse1"."name" AS "likes.horse2.name",
369
- "Horse1"."bio" AS "likes.horse2.bio"
370
- FROM
371
- "Horse"
372
- LEFT JOIN
373
- "Like" ON "Horse"."id" = "Like"."horseId1"
374
- LEFT JOIN
375
- "Horse" AS "Horse1" ON "Like"."horseId2" = "Horse1"."id";
376
- ```
377
-
378
- #### DataSourceOf<T>
379
-
380
- 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:
381
-
382
- ```ts
383
- @D1
384
- class Foo {
385
- ...
386
-
387
- @POST
388
- bar(ds: DataSourceOf<Foo>) {
389
- // ds = "DataSource1" | "DataSource2" | ... | "none"
390
- }
391
- }
392
- ```
393
-
394
- ### One to Many
395
-
396
- Cloesce supports models with `1:M` relationships:
397
-
398
- ```ts
399
- @D1
400
- export class Person {
401
- @PrimaryKey
402
- id: number;
403
-
404
- @OneToMany("personId") // directly references the FK on Dog
405
- dogs: Dog[];
406
-
407
- @DataSource
408
- static readonly default: IncludeTree<Person> = {
409
- dogs: {
410
- person: {
411
- dogs: {
412
- // essentially means: "When you get a person, get their dogs, and get all of those dog's Person, ..."
413
- // we could go on as long as we want
414
- },
415
- },
416
- },
417
- };
418
- }
419
-
420
- @D1
421
- export class Dog {
422
- @PrimaryKey
423
- id: number;
424
-
425
- @ForeignKey(Person)
426
- personId: number;
427
-
428
- // optional navigation property, not needed.
429
- @OneToOne("personId")
430
- person: Person | undefined;
431
- }
432
- ```
433
-
434
- ### Many to Many
435
-
436
- ```ts
437
- @D1
438
- export class Student {
439
- @PrimaryKey
440
- id: number;
441
-
442
- @ManyToMany("StudentsCourses") // unique ID for the generated junction table
443
- courses: Course[];
444
- }
445
-
446
- @D1
447
- export class Course {
448
- @PrimaryKey
449
- id: number;
450
-
451
- @ManyToMany("StudentsCourses") // same unique id => same jct table.
452
- students: Student[];
453
- }
454
- ```
455
-
456
- ### ORM Methods
457
-
458
- Cloesce provides a suite of ORM methods for getting, listing, updating and inserting models.
459
-
460
- #### Upsert
461
-
462
- ```ts
463
- @D1
464
- class Horse {
465
- // ...
466
-
467
- @POST
468
- static async post(@Inject { db }: Env, horse: Horse): Promise<Horse> {
469
- const orm = Orm.fromD1(db);
470
- await orm.upsert(Horse, horse, null);
471
- return (await orm.get(Horse, horse.id, null)).value;
472
- }
473
- }
474
- ```
475
-
476
- #### List, Get
477
-
478
- ```ts
479
- @D1
480
- class Horse {
481
- // ...
482
- @GET
483
- static async get(@Inject { db }: Env, id: number): Promise<Horse> {
484
- const orm = Orm.fromD1(db);
485
- return (await orm.get(Horse, id, "default")).value;
486
- }
487
-
488
- @GET
489
- static async list(@Inject { db }: Env): Promise<Horse[]> {
490
- const orm = Orm.fromD1(db);
491
- return (await orm.list(Horse, "default")).value;
492
- }
493
- }
494
- ```
495
-
496
- ### CRUD Methods
497
-
498
- 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.
499
-
500
- ```ts
501
- @CRUD(["POST", "GET", "LIST"])
502
- @D1
503
- export class CrudHaver {
504
- @PrimaryKey
505
- id: number;
506
- name: string;
507
- }
508
- ```
509
-
510
- which will generate client API methods like:
511
-
512
- ```ts
513
- static async get(
514
- id: number,
515
- dataSource: "none" = "none",
516
- ): Promise<HttpResult<CrudHaver>>
517
- ```
518
-
519
- ### Middleware
520
-
521
- 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.
522
-
523
- Middleware is capable of exiting from the Cloesce Router early with an HTTP Result.
524
-
525
- An example of all levels of middleware is below. All middleware must be defined in the file `app.cloesce.ts`.
526
-
527
- ```ts
528
- @PlainOldObject
529
- export class InjectedThing {
530
- value: string;
531
- }
532
-
533
- @WranglerEnv
534
- export class Env {
535
- db: D1Database;
536
- }
537
-
538
- @D1
539
- @CRUD(["POST"])
540
- export class Model {
541
- @PrimaryKey
542
- id: number;
543
-
544
- @GET
545
- static blockedMethod() {}
546
-
547
- @GET
548
- static getInjectedThing(@Inject thing: InjectedThing): InjectedThing {
549
- return thing;
550
- }
551
- }
552
-
553
- // Middleware instance
554
- const app: CloesceApp = new CloesceApp();
555
-
556
- app.useGlobal((request: Request, env, ir) => {
557
- if (request.method === "POST") {
558
- return { ok: false, status: 401, message: "POST methods aren't allowed." };
559
- }
560
- });
561
-
562
- app.useModel(Model, (request, env, ir) => {
563
- ir.set(InjectedThing.name, {
564
- value: "hello world",
565
- });
566
- });
567
-
568
- app.useMethod(Model, "blockedMethod", (request, env, ir) => {
569
- return { ok: false, status: 401, message: "Blocked method" };
570
- });
571
-
572
- // Exporting the instance is required
573
- export default app;
574
- ```
575
-
576
- 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.
577
-
578
- ### Plain Old Objects
579
-
580
- Simple non-model objects can be returned and serialized from a model method:
581
-
582
- ```ts
583
- @PlainOldObject
584
- export class CatStuff {
585
- catFacts: string[],
586
- catNames: string[],
587
- }
588
-
589
- @D1
590
- export class Cat {
591
- @PrimaryKey
592
- id: number;
593
-
594
- @GET
595
- query(): CatStuff {
596
- return {
597
- catFacts: ["cats r cool"],
598
- catNames: ["reginald"]
599
- }
600
- }
601
- }
602
- ```
603
-
604
- ### HttpResult
605
-
606
- Methods can return any kind of status code via the `HttpResult` wrapper:
607
-
608
- ```ts
609
- @D1
610
- class Foo {
611
- ...
612
-
613
- @GET
614
- async foo(): Promise<HttpResult<number>> {
615
- return { ok: false, status: 500, message: "divided by 0"};
616
- }
617
- }
618
- ```
619
-
620
- # Testing the Compiler
621
-
622
- ## Unit Tests
623
-
624
- - `src/frontend/ts` run `npm test`
625
- - `src/generator` run `cargo test`
626
-
627
- ## Integration Tests
628
-
629
- - Regression tests: `cargo run --bin test regression`
630
- - Pass fail extractor tests: `cargo run --bin test run-fail`
631
-
632
- Optionally, pass `--check` if new snapshots should not be created.
633
-
634
- To update integration snapshots, run:
635
-
636
- - `cargo run --bin update`
637
-
638
- To delete any generated snapshots run:
639
-
640
- - `cargo run --bin update -- -d`
641
-
642
- ## E2E
643
-
644
- - `tests/e2e` run `npm test`
645
-
646
- ## Code Formatting
647
-
648
- - `cargo fmt`, `cargo clippy`, `npm run format:fix`