@nage-api/data 1.0.0-beta.2

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.
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * Evaluate a `Where` clause against a plain object.
4
+ *
5
+ * Used by the in-memory driver, and by tests that need to reason about the DSL
6
+ * without a database. SQL and Mongo drivers translate the same clause into
7
+ * their own dialect instead — the semantics here are the reference the shared
8
+ * conformance suite pins down.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.matchesWhere = matchesWhere;
12
+ const LOGICAL_KEYS = new Set(['and', 'or', 'not']);
13
+ /**
14
+ * Match a SQL `LIKE` pattern: `%` is any run, `_` is a single character, and
15
+ * every other character is literal.
16
+ *
17
+ * This used to translate the pattern into a regular expression, escaping the
18
+ * metacharacters so a caller could not smuggle one in. Escaping was not the
19
+ * whole risk: `%` became `.*`, and a pattern with a handful of them backtracked
20
+ * exponentially, so `{ name: { like: '%%%%%%%%%%%%X' } }` — a client-supplied
21
+ * operand on any model exposing the default operator list — pinned the event
22
+ * loop for minutes on a row value of a few dozen characters.
23
+ *
24
+ * Walking the two strings instead is linear in the common case and bounded by
25
+ * O(pattern × value) in the worst, because only the most recent `%` is ever
26
+ * reconsidered. Building no regular expression also leaves no escaping to get
27
+ * wrong.
28
+ */
29
+ function matchesLike(value, pattern) {
30
+ let valueIndex = 0;
31
+ let patternIndex = 0;
32
+ // Where to resume from if the greedy `%` turns out to have eaten too much.
33
+ let wildcardIndex = -1;
34
+ let resumeIndex = 0;
35
+ while (valueIndex < value.length) {
36
+ const patternChar = patternIndex < pattern.length ? pattern[patternIndex] : undefined;
37
+ // `%` is tested before the literal comparison on purpose: a value that
38
+ // itself contains a percent sign must not consume the wildcard as a literal.
39
+ if (patternChar === '%') {
40
+ wildcardIndex = patternIndex;
41
+ resumeIndex = valueIndex;
42
+ patternIndex += 1;
43
+ continue;
44
+ }
45
+ if (patternChar === '_' || (patternChar !== undefined && patternChar === value[valueIndex])) {
46
+ valueIndex += 1;
47
+ patternIndex += 1;
48
+ continue;
49
+ }
50
+ if (wildcardIndex === -1)
51
+ return false;
52
+ resumeIndex += 1;
53
+ valueIndex = resumeIndex;
54
+ patternIndex = wildcardIndex + 1;
55
+ }
56
+ while (patternIndex < pattern.length && pattern[patternIndex] === '%')
57
+ patternIndex += 1;
58
+ // The whole value has to be consumed: `like: 'Ham'` does not match 'Hammer'.
59
+ return patternIndex === pattern.length;
60
+ }
61
+ function compare(actual, operator, operand) {
62
+ switch (operator) {
63
+ case 'eq':
64
+ return actual === operand;
65
+ case 'ne':
66
+ return actual !== operand;
67
+ case 'gt':
68
+ return isComparable(actual, operand) && actual > operand;
69
+ case 'gte':
70
+ return isComparable(actual, operand) && actual >= operand;
71
+ case 'lt':
72
+ return isComparable(actual, operand) && actual < operand;
73
+ case 'lte':
74
+ return isComparable(actual, operand) && actual <= operand;
75
+ case 'in':
76
+ return Array.isArray(operand) && operand.includes(actual);
77
+ case 'nin':
78
+ return Array.isArray(operand) && !operand.includes(actual);
79
+ case 'like':
80
+ return typeof actual === 'string' && typeof operand === 'string'
81
+ ? matchesLike(actual, operand)
82
+ : false;
83
+ case 'ilike':
84
+ return typeof actual === 'string' && typeof operand === 'string'
85
+ ? matchesLike(actual.toLowerCase(), operand.toLowerCase())
86
+ : false;
87
+ case 'between':
88
+ return (Array.isArray(operand) &&
89
+ operand.length === 2 &&
90
+ isComparable(actual, operand[0]) &&
91
+ isComparable(actual, operand[1]) &&
92
+ actual >= operand[0] &&
93
+ actual <= operand[1]);
94
+ case 'isNull':
95
+ return operand === true ? actual === null || actual === undefined : actual != null;
96
+ default:
97
+ return false;
98
+ }
99
+ }
100
+ /** Both operands are the same ordered primitive, so `<`/`>` are meaningful. */
101
+ function isComparable(left, right) {
102
+ return ((typeof left === 'number' && typeof right === 'number') ||
103
+ (typeof left === 'string' && typeof right === 'string'));
104
+ }
105
+ function isPlainObject(value) {
106
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
107
+ }
108
+ /** Does `record` satisfy `where`? An empty clause matches everything. */
109
+ function matchesWhere(record, where) {
110
+ if (where === undefined)
111
+ return true;
112
+ for (const [key, value] of Object.entries(where)) {
113
+ if (value === undefined)
114
+ continue;
115
+ if (LOGICAL_KEYS.has(key)) {
116
+ const clauses = (Array.isArray(value) ? value : [value]);
117
+ if (key === 'and' && !clauses.every((clause) => matchesWhere(record, clause)))
118
+ return false;
119
+ if (key === 'or' && !clauses.some((clause) => matchesWhere(record, clause)))
120
+ return false;
121
+ if (key === 'not' && clauses.some((clause) => matchesWhere(record, clause)))
122
+ return false;
123
+ continue;
124
+ }
125
+ const actual = record[key];
126
+ if (isPlainObject(value)) {
127
+ for (const [operator, operand] of Object.entries(value)) {
128
+ if (!compare(actual, operator, operand))
129
+ return false;
130
+ }
131
+ continue;
132
+ }
133
+ // A bare value is shorthand for equality.
134
+ if (actual !== value)
135
+ return false;
136
+ }
137
+ return true;
138
+ }
139
+ //# sourceMappingURL=where-matcher.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `ModelService<TEntity>` — the retained template-method service (PLAN.md §14.1).
3
+ *
4
+ * The legacy `ModelService` was the framework's most productive idea and its
5
+ * worst typing: `body: any`, `records: any[]`, `payload: any` at the `Job`
6
+ * boundary defeated the generic it advertised. The shape is kept — a `Job` flows
7
+ * through `doBefore*` / `doAfter*` hooks around a data-layer call — but every
8
+ * member is typed against the entity, and the hooks run **inside** the
9
+ * transaction so a failing `doAfterCreate` rolls the write back.
10
+ *
11
+ * Ownership scoping (`doBeforeRead(job) { job.query.where.user_id = job.owner.id }`)
12
+ * is first-class rather than a documented convention: `scopeToOwner()` does it
13
+ * with types.
14
+ */
15
+ import type { DeepPartial, Id, Job, Paginated, Query, RepositoryPort, UnitOfWork } from '@nage-api/contracts';
16
+ import { NotFoundError } from '@nage-api/core';
17
+ export interface ModelServiceOptions {
18
+ /** Runs each mutating operation and its hooks atomically. */
19
+ readonly unitOfWork?: UnitOfWork;
20
+ }
21
+ /**
22
+ * Base class for a model's service layer.
23
+ *
24
+ * Subclasses override only the hooks they need; every hook defaults to a no-op.
25
+ */
26
+ export declare abstract class ModelService<TEntity extends {
27
+ id: Id;
28
+ }, TBody = DeepPartial<TEntity>> {
29
+ #private;
30
+ protected readonly repository: RepositoryPort<TEntity>;
31
+ private readonly options;
32
+ constructor(repository: RepositoryPort<TEntity>, options?: ModelServiceOptions);
33
+ /** Runs before every read (`findAll`, `findOne`, `findById`, `count`). */
34
+ protected doBeforeRead(_job: Job<TEntity, TBody>): void | Promise<void>;
35
+ protected doAfterFindAll(_job: Job<TEntity, TBody>): void | Promise<void>;
36
+ protected doAfterFindOne(_job: Job<TEntity, TBody>): void | Promise<void>;
37
+ /** Runs before every write; the last chance to reject or amend the body. */
38
+ protected doBeforeWrite(_job: Job<TEntity, TBody>): void | Promise<void>;
39
+ protected doBeforeCreate(_job: Job<TEntity, TBody>): void | Promise<void>;
40
+ /** Runs inside the transaction: throwing here rolls the create back. */
41
+ protected doAfterCreate(_job: Job<TEntity, TBody>): void | Promise<void>;
42
+ protected doBeforeUpdate(_job: Job<TEntity, TBody>): void | Promise<void>;
43
+ protected doAfterUpdate(_job: Job<TEntity, TBody>): void | Promise<void>;
44
+ protected doBeforeDelete(_job: Job<TEntity, TBody>): void | Promise<void>;
45
+ protected doAfterDelete(_job: Job<TEntity, TBody>): void | Promise<void>;
46
+ findAll(query?: Query<TEntity>): Promise<Paginated<TEntity>>;
47
+ findOne(query?: Query<TEntity>): Promise<TEntity | null>;
48
+ /**
49
+ * Read one record by id, honouring read hooks.
50
+ *
51
+ * Scoping applies here too: an ownership hook that adds `where.user_id` means
52
+ * a caller cannot read someone else's record by guessing its id.
53
+ */
54
+ findById(id: Id, query?: Query<TEntity>): Promise<TEntity | null>;
55
+ /** Like `findById`, but a missing record is a `RESOURCE_NOT_FOUND`. */
56
+ findByIdOrFail(id: Id, query?: Query<TEntity>): Promise<TEntity>;
57
+ count(query?: Query<TEntity>): Promise<number>;
58
+ create(body: TBody): Promise<TEntity>;
59
+ update(id: Id, body: TBody): Promise<TEntity>;
60
+ delete(id: Id, mode?: 'soft' | 'hard'): Promise<void>;
61
+ restore(id: Id): Promise<TEntity>;
62
+ /**
63
+ * Constrain a job's query to the records its owner may see — the pattern the
64
+ * legacy docs described as `job.where.user_id = job.owner.id`, typed.
65
+ */
66
+ protected scopeToOwner(job: Job<TEntity, TBody>, field: keyof TEntity & string): void;
67
+ protected notFound(id: Id): NotFoundError;
68
+ }
69
+ //# sourceMappingURL=model.service.d.ts.map
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * `ModelService<TEntity>` — the retained template-method service (PLAN.md §14.1).
4
+ *
5
+ * The legacy `ModelService` was the framework's most productive idea and its
6
+ * worst typing: `body: any`, `records: any[]`, `payload: any` at the `Job`
7
+ * boundary defeated the generic it advertised. The shape is kept — a `Job` flows
8
+ * through `doBefore*` / `doAfter*` hooks around a data-layer call — but every
9
+ * member is typed against the entity, and the hooks run **inside** the
10
+ * transaction so a failing `doAfterCreate` rolls the write back.
11
+ *
12
+ * Ownership scoping (`doBeforeRead(job) { job.query.where.user_id = job.owner.id }`)
13
+ * is first-class rather than a documented convention: `scopeToOwner()` does it
14
+ * with types.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.ModelService = void 0;
18
+ const core_1 = require("@nage-api/core");
19
+ /**
20
+ * Base class for a model's service layer.
21
+ *
22
+ * Subclasses override only the hooks they need; every hook defaults to a no-op.
23
+ */
24
+ class ModelService {
25
+ repository;
26
+ options;
27
+ constructor(repository, options = {}) {
28
+ this.repository = repository;
29
+ this.options = options;
30
+ }
31
+ // ── Lifecycle hooks ───────────────────────────────────────────────────────
32
+ // Read hooks shape the query before it runs; write hooks see the record after.
33
+ /** Runs before every read (`findAll`, `findOne`, `findById`, `count`). */
34
+ doBeforeRead(_job) {
35
+ return undefined;
36
+ }
37
+ doAfterFindAll(_job) {
38
+ return undefined;
39
+ }
40
+ doAfterFindOne(_job) {
41
+ return undefined;
42
+ }
43
+ /** Runs before every write; the last chance to reject or amend the body. */
44
+ doBeforeWrite(_job) {
45
+ return undefined;
46
+ }
47
+ doBeforeCreate(_job) {
48
+ return undefined;
49
+ }
50
+ /** Runs inside the transaction: throwing here rolls the create back. */
51
+ doAfterCreate(_job) {
52
+ return undefined;
53
+ }
54
+ doBeforeUpdate(_job) {
55
+ return undefined;
56
+ }
57
+ doAfterUpdate(_job) {
58
+ return undefined;
59
+ }
60
+ doBeforeDelete(_job) {
61
+ return undefined;
62
+ }
63
+ doAfterDelete(_job) {
64
+ return undefined;
65
+ }
66
+ // ── Operations ────────────────────────────────────────────────────────────
67
+ async findAll(query = {}) {
68
+ const job = this.#job('findAll', { query });
69
+ await this.doBeforeRead(job);
70
+ const page = await this.repository.findAll(job.query);
71
+ job.records = [...page.records];
72
+ job.count = page.pagination.count;
73
+ await this.doAfterFindAll(job);
74
+ return { records: job.records, pagination: { ...page.pagination, count: job.count } };
75
+ }
76
+ async findOne(query = {}) {
77
+ const job = this.#job('findOne', { query });
78
+ await this.doBeforeRead(job);
79
+ const record = await this.repository.findOne(job.query);
80
+ if (record === null)
81
+ return null;
82
+ job.record = record;
83
+ await this.doAfterFindOne(job);
84
+ return job.record;
85
+ }
86
+ /**
87
+ * Read one record by id, honouring read hooks.
88
+ *
89
+ * Scoping applies here too: an ownership hook that adds `where.user_id` means
90
+ * a caller cannot read someone else's record by guessing its id.
91
+ */
92
+ async findById(id, query = {}) {
93
+ const job = this.#job('findById', { query, id });
94
+ await this.doBeforeRead(job);
95
+ return this.findOne({ ...job.query, where: withId(job.query.where, id) });
96
+ }
97
+ /** Like `findById`, but a missing record is a `RESOURCE_NOT_FOUND`. */
98
+ async findByIdOrFail(id, query = {}) {
99
+ const record = await this.findById(id, query);
100
+ if (record === null)
101
+ throw this.notFound(id);
102
+ return record;
103
+ }
104
+ async count(query = {}) {
105
+ const job = this.#job('count', { query });
106
+ await this.doBeforeRead(job);
107
+ return this.repository.count(job.query);
108
+ }
109
+ async create(body) {
110
+ const job = this.#job('create', { body });
111
+ return this.#transactional(async (tx) => {
112
+ await this.doBeforeWrite(job);
113
+ await this.doBeforeCreate(job);
114
+ job.record = await this.repository.create(job.body, tx === undefined ? {} : { tx });
115
+ await this.doAfterCreate(job);
116
+ return job.record;
117
+ });
118
+ }
119
+ async update(id, body) {
120
+ const existing = await this.findById(id);
121
+ if (existing === null)
122
+ throw this.notFound(id);
123
+ const job = this.#job('update', { body, id });
124
+ job.record = existing;
125
+ return this.#transactional(async (tx) => {
126
+ await this.doBeforeWrite(job);
127
+ await this.doBeforeUpdate(job);
128
+ job.record = await this.repository.update(id, job.body, tx === undefined ? {} : { tx });
129
+ await this.doAfterUpdate(job);
130
+ return job.record;
131
+ });
132
+ }
133
+ async delete(id, mode = 'soft') {
134
+ const existing = await this.findById(id);
135
+ if (existing === null)
136
+ throw this.notFound(id);
137
+ const job = this.#job('delete', { id, deleteMode: mode });
138
+ job.record = existing;
139
+ await this.#transactional(async (tx) => {
140
+ await this.doBeforeDelete(job);
141
+ await this.repository.delete(id, tx === undefined ? { mode } : { mode, tx });
142
+ await this.doAfterDelete(job);
143
+ return undefined;
144
+ });
145
+ }
146
+ async restore(id) {
147
+ return this.#transactional(async (tx) => this.repository.restore(id, tx === undefined ? {} : { tx }));
148
+ }
149
+ // ── Helpers for subclasses ────────────────────────────────────────────────
150
+ /**
151
+ * Constrain a job's query to the records its owner may see — the pattern the
152
+ * legacy docs described as `job.where.user_id = job.owner.id`, typed.
153
+ */
154
+ scopeToOwner(job, field) {
155
+ const owner = job.owner;
156
+ if (owner === undefined)
157
+ return;
158
+ job.query = {
159
+ ...job.query,
160
+ where: { ...job.query.where, [field]: owner.id },
161
+ };
162
+ }
163
+ notFound(id) {
164
+ return new core_1.NotFoundError({
165
+ detail: `${this.repository.descriptor.name} ${String(id)} was not found`,
166
+ meta: { model: this.repository.descriptor.name, id },
167
+ });
168
+ }
169
+ // ── Internals ─────────────────────────────────────────────────────────────
170
+ #job(action, input) {
171
+ return (0, core_1.createJob)({
172
+ action,
173
+ ...(input.query === undefined ? {} : { query: input.query }),
174
+ ...(input.body === undefined ? {} : { body: input.body }),
175
+ ...(input.id === undefined ? {} : { id: input.id }),
176
+ ...(input.deleteMode === undefined ? {} : { deleteMode: input.deleteMode }),
177
+ });
178
+ }
179
+ /** Run `fn` in a transaction when one is available, directly otherwise. */
180
+ async #transactional(fn) {
181
+ const unitOfWork = this.options.unitOfWork;
182
+ if (unitOfWork === undefined)
183
+ return fn(undefined);
184
+ return unitOfWork.run(async (tx) => fn(tx));
185
+ }
186
+ }
187
+ exports.ModelService = ModelService;
188
+ function withId(where, id) {
189
+ return { ...where, id };
190
+ }
191
+ //# sourceMappingURL=model.service.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tokens owned by `@nage-api/data`.
3
+ *
4
+ * `NAGE_UNIT_OF_WORK` and the per-model repository tokens are declared by
5
+ * `@nage-api/core`, since a feature package injecting a repository must not have to
6
+ * depend on the data package to name it.
7
+ */
8
+ import { type Token } from '@nage-api/core';
9
+ import type { DataSourceHealth } from '@nage-api/contracts';
10
+ /** Connection probe published by the active driver (§21). */
11
+ export declare const NAGE_DATA_HEALTH: Token<DataSourceHealth>;
12
+ //# sourceMappingURL=tokens.d.ts.map
package/dist/tokens.js ADDED
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Tokens owned by `@nage-api/data`.
4
+ *
5
+ * `NAGE_UNIT_OF_WORK` and the per-model repository tokens are declared by
6
+ * `@nage-api/core`, since a feature package injecting a repository must not have to
7
+ * depend on the data package to name it.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.NAGE_DATA_HEALTH = void 0;
11
+ const core_1 = require("@nage-api/core");
12
+ /** Connection probe published by the active driver (§21). */
13
+ exports.NAGE_DATA_HEALTH = (0, core_1.createToken)('NAGE_DATA_HEALTH');
14
+ //# sourceMappingURL=tokens.js.map
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@nage-api/data",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Engine-agnostic data layer for @nage-api — repository contract, typed query DSL, ModelService, migrations",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/.tsbuildinfo",
20
+ "!dist/**/*.map",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@nage-api/contracts": "1.0.0-beta.2",
28
+ "@nage-api/core": "1.0.0-beta.2"
29
+ },
30
+ "peerDependencies": {
31
+ "@nestjs/common": "^11.0.0",
32
+ "@nestjs/core": "^11.0.0",
33
+ "reflect-metadata": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@nestjs/common": "11.1.29",
37
+ "@nestjs/core": "11.1.29",
38
+ "@nestjs/testing": "11.1.29",
39
+ "@swc/core": "1.15.47",
40
+ "@types/node": "22.20.1",
41
+ "@vitest/coverage-v8": "4.1.10",
42
+ "reflect-metadata": "0.2.2",
43
+ "rimraf": "6.1.3",
44
+ "rxjs": "7.8.2",
45
+ "typescript": "5.9.3",
46
+ "unplugin-swc": "1.5.11",
47
+ "vitest": "4.1.10",
48
+ "@nage-api/testing": "1.0.0-beta.2"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b tsconfig.build.json",
55
+ "clean": "rimraf dist .turbo",
56
+ "typecheck": "tsc -p tsconfig.json --noEmit",
57
+ "test": "vitest run"
58
+ }
59
+ }