@quickservicets/core 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +12 -2
- package/package.json +56 -4
- package/src/adapter.ts +159 -0
- package/src/adapters/memory.ts +245 -0
- package/src/adapters/mongodb.ts +417 -0
- package/src/adapters/postgres-conditions.ts +189 -0
- package/src/adapters/postgres-statements.ts +364 -0
- package/src/adapters/postgres.ts +264 -0
- package/src/adapters/sqlite.ts +283 -0
- package/src/app.ts +305 -0
- package/src/build-steps.ts +53 -0
- package/src/cidr.ts +172 -0
- package/src/commands.ts +88 -0
- package/src/config-error.ts +7 -0
- package/src/config.ts +378 -0
- package/src/context-flags.ts +25 -0
- package/src/entity-model.ts +85 -0
- package/src/entity-tags.ts +38 -0
- package/src/env-vars.ts +149 -0
- package/src/environment.ts +309 -0
- package/src/error-reports.ts +119 -0
- package/src/errors.ts +84 -0
- package/src/event-types.ts +96 -0
- package/src/feature-hooks.ts +53 -0
- package/src/feature-targets.ts +84 -0
- package/src/features/errors/api.ts +9 -0
- package/src/features/errors/index.ts +328 -0
- package/src/features/etag/api.ts +27 -0
- package/src/features/etag/index.ts +186 -0
- package/src/features/etag/tags.ts +1 -0
- package/src/features/events/api.ts +16 -0
- package/src/features/events/bus.ts +326 -0
- package/src/features/events/commands.ts +83 -0
- package/src/features/events/delivery.ts +324 -0
- package/src/features/events/index.ts +209 -0
- package/src/features/events/listen.ts +38 -0
- package/src/features/events/outbox.ts +260 -0
- package/src/features/events/patterns.ts +47 -0
- package/src/features/events/state.ts +11 -0
- package/src/features/flags/api.ts +50 -0
- package/src/features/flags/commands.ts +116 -0
- package/src/features/flags/evaluate.ts +63 -0
- package/src/features/flags/index.ts +409 -0
- package/src/features/flags/store.ts +38 -0
- package/src/features/health/api.ts +66 -0
- package/src/features/health/checks.ts +133 -0
- package/src/features/health/index.ts +213 -0
- package/src/features/health/state.ts +10 -0
- package/src/features/history/api.ts +51 -0
- package/src/features/history/commands.ts +71 -0
- package/src/features/history/index.ts +209 -0
- package/src/features/history/jobs/prune.ts +5 -0
- package/src/features/history/model.ts +107 -0
- package/src/features/history/prune.ts +165 -0
- package/src/features/history/routes.ts +163 -0
- package/src/features/history/store.ts +132 -0
- package/src/features/idempotency/api.ts +39 -0
- package/src/features/idempotency/index.ts +214 -0
- package/src/features/idempotency/store.ts +154 -0
- package/src/features/public.ts +5 -0
- package/src/features/queues/api.ts +76 -0
- package/src/features/queues/broker.ts +142 -0
- package/src/features/queues/client.ts +48 -0
- package/src/features/queues/commands.ts +112 -0
- package/src/features/queues/database.ts +656 -0
- package/src/features/queues/handle.ts +46 -0
- package/src/features/queues/index.ts +370 -0
- package/src/features/queues/message-options.ts +38 -0
- package/src/features/queues/message.ts +94 -0
- package/src/features/queues/publish.ts +48 -0
- package/src/features/queues/queue.ts +114 -0
- package/src/features/queues/state.ts +43 -0
- package/src/features/realtime/api.ts +28 -0
- package/src/features/realtime/changes.ts +140 -0
- package/src/features/realtime/follow.ts +78 -0
- package/src/features/realtime/index.ts +309 -0
- package/src/features/realtime/source.ts +179 -0
- package/src/features/realtime/stream.ts +109 -0
- package/src/features/security/api.ts +47 -0
- package/src/features/security/cors.ts +215 -0
- package/src/features/security/db-store.ts +179 -0
- package/src/features/security/exempt.ts +59 -0
- package/src/features/security/files.ts +16 -0
- package/src/features/security/headers.ts +87 -0
- package/src/features/security/index.ts +378 -0
- package/src/features/security/ip.ts +61 -0
- package/src/features/security/limits.ts +127 -0
- package/src/features/security/masking.ts +181 -0
- package/src/features/security/pipeline.ts +153 -0
- package/src/features/security/proxy.ts +60 -0
- package/src/features/security/rate-limit.ts +198 -0
- package/src/features/security/routes.ts +55 -0
- package/src/features/security/shedding.ts +63 -0
- package/src/features/security/store.ts +134 -0
- package/src/features.ts +854 -0
- package/src/files/access.ts +127 -0
- package/src/files/cleanup.ts +74 -0
- package/src/files/detect.ts +186 -0
- package/src/files/field-limits.ts +37 -0
- package/src/files/limits.ts +35 -0
- package/src/files/multipart.ts +259 -0
- package/src/files/references.ts +115 -0
- package/src/files/upload.ts +184 -0
- package/src/files.ts +57 -0
- package/src/fluent-query.ts +439 -0
- package/src/hooks.ts +124 -0
- package/src/http/body.ts +128 -0
- package/src/http/crud.ts +205 -0
- package/src/http/docs.ts +46 -0
- package/src/http/pipeline.ts +81 -0
- package/src/http/query-items.ts +82 -0
- package/src/http/router.ts +178 -0
- package/src/http/selection.ts +103 -0
- package/src/http/server.ts +345 -0
- package/src/http/sse.ts +79 -0
- package/src/http/stored-records.ts +30 -0
- package/src/http/write-conditions.ts +18 -0
- package/src/index.ts +409 -0
- package/src/jobs/attempts.ts +53 -0
- package/src/jobs/job-run.ts +90 -0
- package/src/jobs/job.ts +120 -0
- package/src/jobs/lifecycle.ts +105 -0
- package/src/jobs/observers.ts +40 -0
- package/src/jobs/retention.ts +113 -0
- package/src/jobs/schedule.ts +450 -0
- package/src/jobs/scheduler.ts +513 -0
- package/src/jobs/triggers.ts +93 -0
- package/src/legacy-names.ts +71 -0
- package/src/lifecycle.ts +369 -0
- package/src/logger.ts +142 -0
- package/src/matching.ts +101 -0
- package/src/middleware.ts +106 -0
- package/src/migrations.ts +32 -0
- package/src/model.ts +221 -0
- package/src/naming.ts +30 -0
- package/src/openapi.ts +200 -0
- package/src/operation-context.ts +57 -0
- package/src/options.ts +796 -0
- package/src/populate.ts +121 -0
- package/src/projection.ts +159 -0
- package/src/query.ts +295 -0
- package/src/rates.ts +30 -0
- package/src/referential-integrity.ts +314 -0
- package/src/relations.ts +289 -0
- package/src/repository.ts +728 -0
- package/src/secrets.ts +29 -0
- package/src/service.ts +153 -0
- package/src/sql/aggregates.ts +35 -0
- package/src/sql/conditions.ts +108 -0
- package/src/sql/relations.ts +70 -0
- package/src/sql/schema.ts +226 -0
- package/src/sql/transaction-scope.ts +50 -0
- package/src/storage/local.ts +40 -0
- package/src/storage/s3.ts +55 -0
- package/src/storage/storage.ts +41 -0
- package/src/testing/adapter-contract.ts +550 -0
- package/src/testing/attack-suite.ts +324 -0
- package/src/testing/entities.ts +31 -0
- package/src/testing/file-samples.ts +79 -0
- package/src/testing/index.ts +9 -0
- package/src/testing/postgres.ts +57 -0
- package/src/testing/queue-broker-contract.ts +411 -0
- package/src/testing/sql.ts +15 -0
- package/src/testing/storage-contract.ts +32 -0
- package/src/transactions.ts +116 -0
- package/src/validation.ts +230 -0
- package/src/workers.ts +156 -0
- package/src/write-observers.ts +108 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ricardo Rauber Pereira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quickservicets/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime of [QuickService.ts](https://github.com/ricardorauber/quickservicets), a backend framework for [Bun](https://bun.sh): write your entities as TypeScript types in `entities/`, and get CRUD routes, validation, filters, relations, file uploads, migrations and OpenAPI docs on an in-memory, SQLite, PostgreSQL or MongoDB database.
|
|
4
|
+
|
|
5
|
+
Create a project with the CLI, which adds this package for you:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx quickservicets init my-api --db sqlite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Documentation and source: https://github.com/ricardorauber/quickservicets
|
|
12
|
+
|
|
13
|
+
MIT license.
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quickservicets/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The QuickService.ts runtime: build typed, validated and documented REST APIs on Bun from TypeScript entity types.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"quickservicets",
|
|
7
|
+
"bun",
|
|
8
|
+
"typescript",
|
|
9
|
+
"rest",
|
|
10
|
+
"api",
|
|
11
|
+
"backend",
|
|
12
|
+
"framework",
|
|
13
|
+
"crud",
|
|
14
|
+
"openapi"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ricardorauber/quickservicets.git",
|
|
20
|
+
"directory": "packages/core"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ricardorauber/quickservicets#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/ricardorauber/quickservicets/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "src/index.ts",
|
|
28
|
+
"types": "src/index.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.ts",
|
|
31
|
+
"./testing": "./src/testing/index.ts",
|
|
32
|
+
"./features/*": "./src/features/*"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"drizzle-orm": "0.45.4",
|
|
36
|
+
"mongodb": "7.7.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"drizzle-kit": "0.31.11"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"bun": ">=1.4.2"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"src",
|
|
49
|
+
"!src/**/*.test.ts",
|
|
50
|
+
"!src/**/test-support.ts",
|
|
51
|
+
"!src/**/*-test-support.ts",
|
|
52
|
+
"!src/**/__snapshots__",
|
|
53
|
+
"!src/**/fixtures",
|
|
54
|
+
"!src/**/.generated",
|
|
55
|
+
"README.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
]
|
|
58
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { EntityModel } from "./model.ts";
|
|
2
|
+
import type { Condition, SortField } from "./query.ts";
|
|
3
|
+
|
|
4
|
+
/** A record as stored by an adapter: the entity fields plus the system fields. */
|
|
5
|
+
export type StoredRecord = Record<string, unknown> & {
|
|
6
|
+
id: string;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
updatedAt: Date;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Columns a read may skip. */
|
|
12
|
+
export interface ReadColumns {
|
|
13
|
+
/**
|
|
14
|
+
* Top-level fields not to read from the database (stored as the entity's own columns); the returned records leave
|
|
15
|
+
* them out. Adapters that cannot skip columns may return them.
|
|
16
|
+
*/
|
|
17
|
+
omit?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FindManyOptions extends ReadColumns {
|
|
21
|
+
where: Condition[];
|
|
22
|
+
sort: SortField[];
|
|
23
|
+
limit: number;
|
|
24
|
+
offset: number;
|
|
25
|
+
/** False skips counting the matches; `total` is then the number of records returned (default true). */
|
|
26
|
+
withTotal?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FindManyResult {
|
|
30
|
+
data: StoredRecord[];
|
|
31
|
+
total: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One aggregate computed by `DatabaseAdapter.aggregate`: the number of matches (only those that also satisfy `where`,
|
|
36
|
+
* when given), or a function over a number or date field.
|
|
37
|
+
*/
|
|
38
|
+
export type Aggregate =
|
|
39
|
+
| { fn: "count"; where?: Condition[] }
|
|
40
|
+
| { fn: "sum" | "avg" | "min" | "max"; field: string };
|
|
41
|
+
|
|
42
|
+
/** Aggregate results by name: counts are numbers, the others numbers or dates, or null without values. */
|
|
43
|
+
export type AggregateValues = Record<string, number | Date | null>;
|
|
44
|
+
|
|
45
|
+
/** A reference array, field `field` of `owner`, from which a delete removes the deleted record's id. */
|
|
46
|
+
export interface ReferencePull {
|
|
47
|
+
owner: EntityModel;
|
|
48
|
+
field: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The records one record references through a reference array: those listed in field `field` of the `owner` record
|
|
53
|
+
* with id `id`, which are records of `target`.
|
|
54
|
+
*/
|
|
55
|
+
export interface RelatedScope {
|
|
56
|
+
owner: EntityModel;
|
|
57
|
+
id: string;
|
|
58
|
+
field: string;
|
|
59
|
+
target: EntityModel;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AdapterKind = "memory" | "sqlite" | "postgres" | "mongodb";
|
|
63
|
+
|
|
64
|
+
export interface ConnectOptions {
|
|
65
|
+
/** The resolved default schema (see `loadConfig`), or null for the database's own default. */
|
|
66
|
+
defaultSchema?: string | null;
|
|
67
|
+
/** The server processes sharing the adapter's connection pool size, each opening its share (default 1). */
|
|
68
|
+
workers?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DatabaseAdapter {
|
|
72
|
+
/** Identifies built-in adapters, so tools such as migrations know the database dialect. */
|
|
73
|
+
readonly kind?: AdapterKind;
|
|
74
|
+
connect(models: EntityModel[], options?: ConnectOptions): Promise<void>;
|
|
75
|
+
disconnect(): Promise<void>;
|
|
76
|
+
findById(entity: EntityModel, id: string, options?: ReadColumns): Promise<StoredRecord | null>;
|
|
77
|
+
findOne(entity: EntityModel, where: Condition[]): Promise<StoredRecord | null>;
|
|
78
|
+
findMany(entity: EntityModel, options: FindManyOptions): Promise<FindManyResult>;
|
|
79
|
+
/** Stores a new record and resolves the record as stored. */
|
|
80
|
+
insert(entity: EntityModel, record: StoredRecord): Promise<StoredRecord>;
|
|
81
|
+
/**
|
|
82
|
+
* Updates the record with that id; with `where`, only when the record also matches those conditions, checked in the
|
|
83
|
+
* same atomic write. Resolves the record as stored after the update, or null when no record was updated.
|
|
84
|
+
*/
|
|
85
|
+
updateById(entity: EntityModel, id: string, changes: Record<string, unknown>, where?: Condition[]): Promise<StoredRecord | null>;
|
|
86
|
+
deleteById(entity: EntityModel, id: string): Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* Atomically adds each number of `amounts` to that field of the record with `id` and returns the record; when no
|
|
89
|
+
* record has that id, inserts `insert` with the amounts as its values (optional). Concurrent calls never lose a count.
|
|
90
|
+
*/
|
|
91
|
+
increment?(entity: EntityModel, id: string, amounts: Record<string, number>, insert: StoredRecord): Promise<StoredRecord>;
|
|
92
|
+
/**
|
|
93
|
+
* Inserts the record unless a stored record has the same value of its unique field `unique`, resolving null then;
|
|
94
|
+
* a conflict never fails the current transaction, and a conflicting insert of another uncommitted transaction is
|
|
95
|
+
* waited for (optional; without it, callers insert and catch `UniqueViolationError`).
|
|
96
|
+
*/
|
|
97
|
+
insertOrSkip?(entity: EntityModel, record: StoredRecord, unique: string): Promise<StoredRecord | null>;
|
|
98
|
+
/**
|
|
99
|
+
* Deletes the record with that id and, in the same atomic write, removes its id from the junction-stored reference
|
|
100
|
+
* arrays `pulls` without rewriting their other items. Every record that held the id gets `updatedAt`, or 1 ms after
|
|
101
|
+
* its stored `updatedAt` when that is not earlier. Resolves false when no record was deleted. Optional: without it,
|
|
102
|
+
* a delete rewrites each reference array that held the id.
|
|
103
|
+
*/
|
|
104
|
+
deleteAndPull?(entity: EntityModel, id: string, pulls: ReferencePull[], updatedAt: Date): Promise<boolean>;
|
|
105
|
+
count(entity: EntityModel, where: Condition[]): Promise<number>;
|
|
106
|
+
/**
|
|
107
|
+
* Computes every aggregate over the records matching `where` in one statement (or pipeline). Counts are 0 and the
|
|
108
|
+
* other aggregates null when no record has a value.
|
|
109
|
+
*/
|
|
110
|
+
aggregate(entity: EntityModel, where: Condition[], aggregates: Record<string, Aggregate>): Promise<AggregateValues>;
|
|
111
|
+
/**
|
|
112
|
+
* A page of the related records of `scope` matching `where`, like `findMany` on the target entity; no records when
|
|
113
|
+
* the owner does not exist. Optional, like `countRelated` and `aggregateRelated`: without them, related queries read
|
|
114
|
+
* the owner's array first and then filter the target by id.
|
|
115
|
+
*/
|
|
116
|
+
findRelated?(scope: RelatedScope, options: FindManyOptions): Promise<FindManyResult>;
|
|
117
|
+
/** The number of related records of `scope` matching `where`; 0 when the owner does not exist. */
|
|
118
|
+
countRelated?(scope: RelatedScope, where: Condition[]): Promise<number>;
|
|
119
|
+
/**
|
|
120
|
+
* Every aggregate over the related records of `scope` matching `where`, in one statement on SQL databases. Resolves
|
|
121
|
+
* null when the owner does not exist; counts are 0 and the other aggregates null when no related record has a value.
|
|
122
|
+
*/
|
|
123
|
+
aggregateRelated?(scope: RelatedScope, where: Condition[], aggregates: Record<string, Aggregate>): Promise<AggregateValues | null>;
|
|
124
|
+
/** Runs `fn` atomically when the database supports transactions, or directly otherwise. */
|
|
125
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
126
|
+
/**
|
|
127
|
+
* Whether a failed `transaction` rolls its writes back: true on the memory adapter, SQLite and PostgreSQL, and on
|
|
128
|
+
* MongoDB replica sets and sharded clusters once connected. Without it, public transactions warn once.
|
|
129
|
+
*/
|
|
130
|
+
readonly supportsRollback?: boolean;
|
|
131
|
+
/** Checks the connection with the cheapest round trip (optional; health checks read one record without it). */
|
|
132
|
+
ping?(): Promise<void>;
|
|
133
|
+
/** Locks a record until the current transaction ends, for read-modify-write updates (optional). */
|
|
134
|
+
lockForUpdate?(entity: EntityModel, id: string): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const FIND_ALL_BATCH = 1000;
|
|
138
|
+
|
|
139
|
+
/** Every record matching `where`, read in pages without counting the matches first. */
|
|
140
|
+
export async function findAll(adapter: DatabaseAdapter, entity: EntityModel, where: Condition[], sort: SortField[]): Promise<StoredRecord[]> {
|
|
141
|
+
// A unique last sort key keeps the pages from overlapping.
|
|
142
|
+
const order: SortField[] = sort.some((item) => item.field === "id") ? sort : [...sort, { field: "id", direction: "asc" }];
|
|
143
|
+
const found: StoredRecord[] = [];
|
|
144
|
+
for (let offset = 0; ; offset += FIND_ALL_BATCH) {
|
|
145
|
+
const { data } = await adapter.findMany(entity, { where, sort: order, limit: FIND_ALL_BATCH, offset, withTotal: false });
|
|
146
|
+
found.push(...data);
|
|
147
|
+
if (data.length < FIND_ALL_BATCH) return found;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class UniqueViolationError extends Error {
|
|
152
|
+
readonly field: string;
|
|
153
|
+
|
|
154
|
+
constructor(field: string) {
|
|
155
|
+
super(`Unique constraint violated on "${field}"`);
|
|
156
|
+
this.name = "UniqueViolationError";
|
|
157
|
+
this.field = field;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UniqueViolationError,
|
|
3
|
+
type Aggregate,
|
|
4
|
+
type AggregateValues,
|
|
5
|
+
type DatabaseAdapter,
|
|
6
|
+
type FindManyOptions,
|
|
7
|
+
type FindManyResult,
|
|
8
|
+
type RelatedScope,
|
|
9
|
+
type StoredRecord,
|
|
10
|
+
} from "../adapter.ts";
|
|
11
|
+
import type { EntityModel } from "../model.ts";
|
|
12
|
+
import { TransactionScope } from "../sql/transaction-scope.ts";
|
|
13
|
+
import type { Condition, SortField } from "../query.ts";
|
|
14
|
+
import { compareValues, matches } from "../matching.ts";
|
|
15
|
+
|
|
16
|
+
type Table = Map<string, StoredRecord>;
|
|
17
|
+
|
|
18
|
+
const isNullish = (value: unknown): boolean => value === null || value === undefined;
|
|
19
|
+
|
|
20
|
+
function sortRecords(records: StoredRecord[], sort: SortField[]): StoredRecord[] {
|
|
21
|
+
return records.sort((left, right) => {
|
|
22
|
+
for (const { field, direction } of sort) {
|
|
23
|
+
const a = left[field];
|
|
24
|
+
const b = right[field];
|
|
25
|
+
let result: number;
|
|
26
|
+
// Nulls sort last ascending and first descending, as in PostgreSQL.
|
|
27
|
+
if (isNullish(a) || isNullish(b)) result = isNullish(a) === isNullish(b) ? 0 : isNullish(a) ? 1 : -1;
|
|
28
|
+
else result = compareValues(a, b);
|
|
29
|
+
if (result !== 0) return direction === "asc" ? result : -result;
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Computes one aggregate like SQL does: null values are skipped, and only counts are 0 without values. */
|
|
36
|
+
function aggregateOf(records: StoredRecord[], aggregate: Aggregate, entity: EntityModel): number | Date | null {
|
|
37
|
+
if (aggregate.fn === "count") {
|
|
38
|
+
const where = aggregate.where ?? [];
|
|
39
|
+
return records.filter((record) => where.every((condition) => matches(record, condition, entity))).length;
|
|
40
|
+
}
|
|
41
|
+
const values = records.map((record) => record[aggregate.field]).filter((value) => !isNullish(value));
|
|
42
|
+
if (values.length === 0) return null;
|
|
43
|
+
if (aggregate.fn === "sum" || aggregate.fn === "avg") {
|
|
44
|
+
const sum = (values as number[]).reduce((total, value) => total + value, 0);
|
|
45
|
+
return aggregate.fn === "sum" ? sum : sum / values.length;
|
|
46
|
+
}
|
|
47
|
+
const pick = values.reduce((best, value) => ((aggregate.fn === "min" ? compareValues(value, best) < 0 : compareValues(value, best) > 0) ? value : best));
|
|
48
|
+
return pick instanceof Date ? new Date(pick) : (pick as number);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The record without the omitted fields. */
|
|
52
|
+
function without(record: StoredRecord, omit: readonly string[] | undefined): StoredRecord {
|
|
53
|
+
for (const field of omit ?? []) delete record[field];
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function memoryAdapter(): DatabaseAdapter {
|
|
58
|
+
const tables = new Map<string, Table>();
|
|
59
|
+
// Transactions run one at a time, like on SQLite; the undo log of the running one restores what it changed.
|
|
60
|
+
const scope = new TransactionScope();
|
|
61
|
+
let undo: (() => void)[] | undefined;
|
|
62
|
+
/** Records how to restore the record `id` of `table` as it is now, when a transaction runs. */
|
|
63
|
+
const remember = (table: Table, id: string) => {
|
|
64
|
+
if (!undo) return;
|
|
65
|
+
const previous = table.get(id);
|
|
66
|
+
undo.push(() => (previous === undefined ? table.delete(id) : table.set(id, previous)));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Each schema is its own namespace, so `users` and `auth/users` never share records.
|
|
70
|
+
const tableFor = (entity: EntityModel): Table => {
|
|
71
|
+
const key = `${entity.schema ?? ""}\u0000${entity.tableName}`;
|
|
72
|
+
let table = tables.get(key);
|
|
73
|
+
if (!table) {
|
|
74
|
+
table = new Map();
|
|
75
|
+
tables.set(key, table);
|
|
76
|
+
}
|
|
77
|
+
return table;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const select = (entity: EntityModel, where: Condition[]): StoredRecord[] =>
|
|
81
|
+
[...tableFor(entity).values()].filter((record) => where.every((condition) => matches(record, condition, entity)));
|
|
82
|
+
|
|
83
|
+
/** One page of `records`, in `sort` order. */
|
|
84
|
+
const page = (records: StoredRecord[], { sort, limit, offset, withTotal = true, omit }: FindManyOptions): FindManyResult => {
|
|
85
|
+
const sorted = sortRecords(records, sort);
|
|
86
|
+
const data = sorted.slice(offset, offset + limit).map((record) => without(structuredClone(record), omit));
|
|
87
|
+
return { data, total: withTotal ? sorted.length : data.length };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const aggregateAll = (entity: EntityModel, records: StoredRecord[], aggregates: Record<string, Aggregate>): AggregateValues => {
|
|
91
|
+
const values: AggregateValues = {};
|
|
92
|
+
for (const [name, aggregate] of Object.entries(aggregates)) values[name] = aggregateOf(records, aggregate, entity);
|
|
93
|
+
return values;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The related records of `scope` matching `where`, or null when the owner does not exist. */
|
|
97
|
+
const selectRelated = ({ owner, id, field, target }: RelatedScope, where: Condition[]): StoredRecord[] | null => {
|
|
98
|
+
const record = tableFor(owner).get(id);
|
|
99
|
+
if (!record) return null;
|
|
100
|
+
const records = tableFor(target);
|
|
101
|
+
const ids = new Set((record[field] as string[] | null) ?? []);
|
|
102
|
+
return [...ids].flatMap((targetId) => {
|
|
103
|
+
const found = records.get(targetId);
|
|
104
|
+
return found && where.every((condition) => matches(found, condition, target)) ? [found] : [];
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const assertUnique = (entity: EntityModel, record: StoredRecord): void => {
|
|
109
|
+
for (const field of entity.fields) {
|
|
110
|
+
if (!field.unique || isNullish(record[field.name])) continue;
|
|
111
|
+
for (const other of tableFor(entity).values()) {
|
|
112
|
+
if (other.id !== record.id && compareValues(other[field.name], record[field.name]) === 0) {
|
|
113
|
+
throw new UniqueViolationError(field.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const { fields, unique } of entity.indexes ?? []) {
|
|
118
|
+
if (!unique) continue;
|
|
119
|
+
for (const other of tableFor(entity).values()) {
|
|
120
|
+
if (other.id !== record.id && fields.every((field) => compareValues(other[field], record[field]) === 0)) {
|
|
121
|
+
throw new UniqueViolationError(fields[0]!);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const normalize = (entity: EntityModel, record: StoredRecord): StoredRecord => {
|
|
128
|
+
const normalized: StoredRecord = { ...record };
|
|
129
|
+
for (const field of entity.fields) {
|
|
130
|
+
if (normalized[field.name] === undefined || (field.type === "refArray" && normalized[field.name] === null)) {
|
|
131
|
+
normalized[field.name] = field.type === "refArray" ? [] : null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return structuredClone(normalized);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
kind: "memory",
|
|
139
|
+
supportsRollback: true,
|
|
140
|
+
async connect(models) {
|
|
141
|
+
for (const model of models) tableFor(model);
|
|
142
|
+
},
|
|
143
|
+
async disconnect() {
|
|
144
|
+
tables.clear();
|
|
145
|
+
},
|
|
146
|
+
insert(entity, record) {
|
|
147
|
+
return scope.run(() => {
|
|
148
|
+
const stored = normalize(entity, record);
|
|
149
|
+
assertUnique(entity, stored);
|
|
150
|
+
remember(tableFor(entity), stored.id);
|
|
151
|
+
tableFor(entity).set(stored.id, stored);
|
|
152
|
+
return structuredClone(stored);
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
insertOrSkip(entity, record, unique) {
|
|
156
|
+
return scope.run(() => {
|
|
157
|
+
const stored = normalize(entity, record);
|
|
158
|
+
const value = stored[unique];
|
|
159
|
+
if (value !== null && value !== undefined && [...tableFor(entity).values()].some((existing) => existing[unique] === value)) return null;
|
|
160
|
+
assertUnique(entity, stored);
|
|
161
|
+
remember(tableFor(entity), stored.id);
|
|
162
|
+
tableFor(entity).set(stored.id, stored);
|
|
163
|
+
return structuredClone(stored);
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
findById(entity, id, options) {
|
|
167
|
+
return scope.run(() => {
|
|
168
|
+
const record = tableFor(entity).get(id);
|
|
169
|
+
return record ? without(structuredClone(record), options?.omit) : null;
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
findOne(entity, where) {
|
|
173
|
+
return scope.run(() => {
|
|
174
|
+
const [record] = select(entity, where);
|
|
175
|
+
return record ? structuredClone(record) : null;
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
findMany(entity, options: FindManyOptions) {
|
|
179
|
+
return scope.run(() => page(select(entity, options.where), options));
|
|
180
|
+
},
|
|
181
|
+
findRelated(related, options) {
|
|
182
|
+
return scope.run(() => page(selectRelated(related, options.where) ?? [], options));
|
|
183
|
+
},
|
|
184
|
+
countRelated(related, where) {
|
|
185
|
+
return scope.run(() => selectRelated(related, where)?.length ?? 0);
|
|
186
|
+
},
|
|
187
|
+
aggregateRelated(related, where, aggregates) {
|
|
188
|
+
return scope.run(() => {
|
|
189
|
+
const records = selectRelated(related, where);
|
|
190
|
+
return records ? aggregateAll(related.target, records, aggregates) : null;
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
updateById(entity, id, changes, where = []) {
|
|
194
|
+
return scope.run(() => {
|
|
195
|
+
const table = tableFor(entity);
|
|
196
|
+
const current = table.get(id);
|
|
197
|
+
if (!current || !where.every((condition) => matches(current, condition, entity))) return null;
|
|
198
|
+
const updated = normalize(entity, { ...current, ...changes, id });
|
|
199
|
+
assertUnique(entity, updated);
|
|
200
|
+
remember(table, id);
|
|
201
|
+
table.set(id, updated);
|
|
202
|
+
return structuredClone(updated);
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
increment(entity, id, amounts, insert) {
|
|
206
|
+
return scope.run(() => {
|
|
207
|
+
const table = tableFor(entity);
|
|
208
|
+
const current = table.get(id);
|
|
209
|
+
const added = current ? Object.fromEntries(Object.entries(amounts).map(([field, amount]) => [field, Number(current[field] ?? 0) + amount])) : amounts;
|
|
210
|
+
const next = normalize(entity, { ...(current ?? insert), ...added, id });
|
|
211
|
+
if (!current) assertUnique(entity, next);
|
|
212
|
+
remember(table, id);
|
|
213
|
+
table.set(id, next);
|
|
214
|
+
return structuredClone(next);
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
deleteById(entity, id) {
|
|
218
|
+
return scope.run(() => {
|
|
219
|
+
remember(tableFor(entity), id);
|
|
220
|
+
return tableFor(entity).delete(id);
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
count(entity, where) {
|
|
224
|
+
return scope.run(() => select(entity, where).length);
|
|
225
|
+
},
|
|
226
|
+
aggregate(entity, where, aggregates) {
|
|
227
|
+
return scope.run(() => aggregateAll(entity, select(entity, where), aggregates));
|
|
228
|
+
},
|
|
229
|
+
async ping() {},
|
|
230
|
+
transaction(fn) {
|
|
231
|
+
return scope.transaction(fn, {
|
|
232
|
+
begin: () => {
|
|
233
|
+
undo = [];
|
|
234
|
+
},
|
|
235
|
+
commit: () => {
|
|
236
|
+
undo = undefined;
|
|
237
|
+
},
|
|
238
|
+
rollback: () => {
|
|
239
|
+
for (const restore of (undo ?? []).reverse()) restore();
|
|
240
|
+
undo = undefined;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|