@rangka/core 0.1.0 → 0.1.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.
- package/package.json +6 -2
- package/.claude/skills/extend-core/SKILL.md +0 -133
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -18
- package/CLAUDE.md +0 -180
- package/src/__tests__/coerce.test.ts +0 -154
- package/src/__tests__/context.test.ts +0 -111
- package/src/__tests__/helpers.ts +0 -21
- package/src/__tests__/index.test.ts +0 -7
- package/src/__tests__/widgets.test.ts +0 -197
- package/src/api/__tests__/handlers.test.ts +0 -389
- package/src/api/__tests__/include-resolver.test.ts +0 -393
- package/src/api/__tests__/middleware.test.ts +0 -100
- package/src/api/__tests__/openapi-schema.test.ts +0 -210
- package/src/api/__tests__/query-parser.test.ts +0 -291
- package/src/api/__tests__/route-generator.test.ts +0 -137
- package/src/api/__tests__/server.test.ts +0 -73
- package/src/api/__tests__/swagger.test.ts +0 -166
- package/src/api/handlers.ts +0 -274
- package/src/api/include-resolver.ts +0 -27
- package/src/api/index.ts +0 -4
- package/src/api/meta-handler.ts +0 -254
- package/src/api/openapi-schema.ts +0 -99
- package/src/api/query-parser.ts +0 -315
- package/src/api/route-generator.ts +0 -448
- package/src/api/server.ts +0 -147
- package/src/api/types.ts +0 -16
- package/src/audit/__tests__/audit.test.ts +0 -144
- package/src/audit/index.ts +0 -3
- package/src/audit/record.ts +0 -69
- package/src/audit/tables.ts +0 -48
- package/src/audit/types.ts +0 -26
- package/src/auth/__tests__/core-module.test.ts +0 -54
- package/src/auth/__tests__/debug.test.ts +0 -47
- package/src/auth/__tests__/field-permissions.test.ts +0 -245
- package/src/auth/__tests__/integration.test.ts +0 -208
- package/src/auth/__tests__/meta-boot.test.ts +0 -538
- package/src/auth/__tests__/model-permissions.test.ts +0 -205
- package/src/auth/__tests__/password.test.ts +0 -29
- package/src/auth/__tests__/permission-registry.test.ts +0 -313
- package/src/auth/__tests__/scope-hook.test.ts +0 -509
- package/src/auth/__tests__/scope-registry.test.ts +0 -297
- package/src/auth/__tests__/scopes.test.ts +0 -66
- package/src/auth/__tests__/session.test.ts +0 -214
- package/src/auth/core-models.ts +0 -52
- package/src/auth/core-module.ts +0 -59
- package/src/auth/debug.ts +0 -157
- package/src/auth/field-permissions.ts +0 -116
- package/src/auth/index.ts +0 -37
- package/src/auth/model-permissions.ts +0 -59
- package/src/auth/password.ts +0 -22
- package/src/auth/permission-registry.ts +0 -171
- package/src/auth/scope-filters.ts +0 -11
- package/src/auth/scope-registry.ts +0 -121
- package/src/auth/scopes.ts +0 -146
- package/src/auth/seed.ts +0 -44
- package/src/auth/session.ts +0 -178
- package/src/auth/types.ts +0 -50
- package/src/boot/__tests__/page-scanning.test.ts +0 -170
- package/src/boot/__tests__/page-utils.test.ts +0 -225
- package/src/boot/__tests__/project-scanner.test.ts +0 -88
- package/src/boot/dependency-sort.ts +0 -82
- package/src/boot/discovery.ts +0 -85
- package/src/boot/index.ts +0 -457
- package/src/boot/page-utils.ts +0 -110
- package/src/boot/project-scanner.ts +0 -397
- package/src/boot/schema-loader.ts +0 -26
- package/src/boot/schema-merger.ts +0 -125
- package/src/boot/traits.ts +0 -25
- package/src/boot/types.ts +0 -73
- package/src/context.ts +0 -105
- package/src/db/__tests__/cascade-delete.test.ts +0 -182
- package/src/db/__tests__/desired-state.test.ts +0 -136
- package/src/db/__tests__/diff-engine.test.ts +0 -635
- package/src/db/__tests__/field-mapper.test.ts +0 -355
- package/src/db/__tests__/introspect.test.ts +0 -70
- package/src/db/__tests__/search-filter.test.ts +0 -45
- package/src/db/__tests__/sequence.test.ts +0 -221
- package/src/db/auto-sync.ts +0 -133
- package/src/db/client.ts +0 -147
- package/src/db/desired-state.ts +0 -98
- package/src/db/diff-engine.ts +0 -305
- package/src/db/field-mapper.ts +0 -504
- package/src/db/filter-applier.ts +0 -89
- package/src/db/include-resolver.ts +0 -40
- package/src/db/index.ts +0 -23
- package/src/db/introspect.ts +0 -265
- package/src/db/model-include-resolver.ts +0 -327
- package/src/db/model-ops.ts +0 -281
- package/src/db/scope-enforcer.ts +0 -37
- package/src/db/types.ts +0 -98
- package/src/errors.ts +0 -41
- package/src/events/__tests__/bus.test.ts +0 -105
- package/src/events/bus.ts +0 -89
- package/src/events/index.ts +0 -2
- package/src/events/types.ts +0 -9
- package/src/external-model/__tests__/computed-fields.test.ts +0 -106
- package/src/external-model/__tests__/field-mapper.test.ts +0 -160
- package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
- package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
- package/src/external-model/__tests__/query-executor.test.ts +0 -284
- package/src/external-model/__tests__/schema-converter.test.ts +0 -174
- package/src/external-model/computed-fields.ts +0 -15
- package/src/external-model/define.ts +0 -5
- package/src/external-model/external-model-ops.ts +0 -108
- package/src/external-model/field-mapper.ts +0 -66
- package/src/external-model/in-memory-ops.ts +0 -107
- package/src/external-model/index.ts +0 -7
- package/src/external-model/mutation-executor.ts +0 -71
- package/src/external-model/query-executor.ts +0 -100
- package/src/external-model/schema-converter.ts +0 -53
- package/src/external-model/types.ts +0 -32
- package/src/fixtures/__tests__/fixtures.test.ts +0 -203
- package/src/fixtures/index.ts +0 -10
- package/src/fixtures/loader.ts +0 -196
- package/src/fixtures/registry.ts +0 -125
- package/src/fixtures/types.ts +0 -33
- package/src/helpers/assert-ownership.ts +0 -19
- package/src/helpers/coerce.ts +0 -28
- package/src/helpers/stamping.ts +0 -28
- package/src/helpers/validation.ts +0 -14
- package/src/hooks/__tests__/context.test.ts +0 -73
- package/src/hooks/__tests__/executor.test.ts +0 -433
- package/src/hooks/__tests__/middleware.test.ts +0 -224
- package/src/hooks/__tests__/registry.test.ts +0 -50
- package/src/hooks/context.ts +0 -89
- package/src/hooks/errors.ts +0 -11
- package/src/hooks/executor.ts +0 -115
- package/src/hooks/index.ts +0 -10
- package/src/hooks/middleware.ts +0 -220
- package/src/hooks/registry.ts +0 -20
- package/src/hooks/types.ts +0 -32
- package/src/index.ts +0 -172
- package/src/jobs/__tests__/enqueue.test.ts +0 -77
- package/src/jobs/__tests__/integration.test.ts +0 -71
- package/src/jobs/__tests__/registry.test.ts +0 -103
- package/src/jobs/__tests__/scheduler.test.ts +0 -92
- package/src/jobs/__tests__/worker-execution.test.ts +0 -202
- package/src/jobs/__tests__/worker.test.ts +0 -119
- package/src/jobs/enqueue.ts +0 -93
- package/src/jobs/index.ts +0 -14
- package/src/jobs/registry.ts +0 -92
- package/src/jobs/scheduler.ts +0 -205
- package/src/jobs/tables.ts +0 -132
- package/src/jobs/types.ts +0 -62
- package/src/jobs/worker.ts +0 -272
- package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
- package/src/model-api/__tests__/extended-api.test.ts +0 -244
- package/src/model-api/__tests__/filter-applier.test.ts +0 -177
- package/src/model-api/__tests__/filter-translator.test.ts +0 -186
- package/src/model-api/__tests__/include-resolver.test.ts +0 -226
- package/src/model-api/__tests__/model-access.test.ts +0 -284
- package/src/model-api/__tests__/query-builder.test.ts +0 -224
- package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
- package/src/model-api/field-access.ts +0 -28
- package/src/model-api/filter-applier.ts +0 -1
- package/src/model-api/filter-translator.ts +0 -67
- package/src/model-api/include-resolver.ts +0 -2
- package/src/model-api/index.ts +0 -86
- package/src/model-api/query-builder.ts +0 -155
- package/src/model-api/scope-enforcer.ts +0 -3
- package/src/model-api/types.ts +0 -139
- package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
- package/src/plugins/__tests__/lifecycle.test.ts +0 -96
- package/src/plugins/__tests__/loader.test.ts +0 -273
- package/src/plugins/__tests__/validator.test.ts +0 -275
- package/src/plugins/adapter-registry.ts +0 -42
- package/src/plugins/define.ts +0 -5
- package/src/plugins/index.ts +0 -28
- package/src/plugins/lifecycle.ts +0 -27
- package/src/plugins/loader.ts +0 -126
- package/src/plugins/types.ts +0 -76
- package/src/plugins/validator.ts +0 -141
- package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
- package/src/schema/registry.ts +0 -93
- package/src/schema/relationships.ts +0 -93
- package/src/schema/types.ts +0 -43
- package/src/services/__tests__/integration.test.ts +0 -63
- package/src/services/__tests__/registry.test.ts +0 -175
- package/src/services/index.ts +0 -13
- package/src/services/registry.ts +0 -156
- package/src/services/types.ts +0 -27
- package/src/validation/__tests__/field-validator.test.ts +0 -195
- package/src/validation/field-validator.ts +0 -113
- package/src/validation/index.ts +0 -1
- package/src/widgets/index.ts +0 -3
- package/src/widgets/slot-validator.ts +0 -87
- package/src/widgets/widget-registry.ts +0 -32
- package/tests/boot.test.ts +0 -323
- package/tests/dependency-sort.test.ts +0 -99
- package/tests/discovery.test.ts +0 -126
- package/tests/registry.test.ts +0 -216
- package/tests/schema-loader.test.ts +0 -52
- package/tests/schema-merger.test.ts +0 -180
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -14
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
Kysely,
|
|
4
|
-
DummyDriver,
|
|
5
|
-
PostgresAdapter,
|
|
6
|
-
PostgresIntrospector,
|
|
7
|
-
PostgresQueryCompiler,
|
|
8
|
-
} from 'kysely';
|
|
9
|
-
import { ModelQueryBuilder } from '../query-builder.js';
|
|
10
|
-
import type { SchemaRegistry } from '../../schema/registry.js';
|
|
11
|
-
import type { ResolvedModel } from '../../schema/types.js';
|
|
12
|
-
import { KyselyModelOps } from '../../db/model-ops.js';
|
|
13
|
-
|
|
14
|
-
function createTestDb(): Kysely<any> {
|
|
15
|
-
return new Kysely<any>({
|
|
16
|
-
dialect: {
|
|
17
|
-
createAdapter: () => new PostgresAdapter(),
|
|
18
|
-
createDriver: () => new DummyDriver(),
|
|
19
|
-
createIntrospector: (db: any) => new PostgresIntrospector(db),
|
|
20
|
-
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function makeModel(qualifiedName: string, traits: string[] = []): ResolvedModel {
|
|
26
|
-
return {
|
|
27
|
-
qualifiedName,
|
|
28
|
-
app: 'test',
|
|
29
|
-
module: qualifiedName.split('.')[0],
|
|
30
|
-
name: qualifiedName.split('.')[1],
|
|
31
|
-
auditLog: false,
|
|
32
|
-
traits,
|
|
33
|
-
fields: [
|
|
34
|
-
{ name: 'id', config: { type: 'uuid' }, provenance: { source: 'base' } },
|
|
35
|
-
{ name: 'name', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
36
|
-
{ name: 'status', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
37
|
-
{ name: 'total', config: { type: 'money' }, provenance: { source: 'base' } },
|
|
38
|
-
{
|
|
39
|
-
name: 'created_at',
|
|
40
|
-
config: { type: 'datetime' },
|
|
41
|
-
provenance: { source: 'trait', trait: 'timestamped' },
|
|
42
|
-
},
|
|
43
|
-
],
|
|
44
|
-
indexes: [],
|
|
45
|
-
} as ResolvedModel;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function makeRegistry(models: ResolvedModel[]): SchemaRegistry {
|
|
49
|
-
return {
|
|
50
|
-
getModel: (qn: string) => models.find((m) => m.qualifiedName === qn),
|
|
51
|
-
getAllModels: () => models,
|
|
52
|
-
getRelationshipsForModel: () => [],
|
|
53
|
-
getFieldsForModel: (qn: string) => models.find((m) => m.qualifiedName === qn)?.fields ?? [],
|
|
54
|
-
} as unknown as SchemaRegistry;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe('ModelQueryBuilder', () => {
|
|
58
|
-
const db = createTestDb();
|
|
59
|
-
const model = makeModel('sales.Order');
|
|
60
|
-
const registry = makeRegistry([model]);
|
|
61
|
-
const ops = new KyselyModelOps({ db, model, registry });
|
|
62
|
-
|
|
63
|
-
function createBuilder() {
|
|
64
|
-
return new ModelQueryBuilder(ops, model, registry);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe('compilation', () => {
|
|
68
|
-
it('generates basic select all from table', () => {
|
|
69
|
-
const builder = createBuilder();
|
|
70
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
71
|
-
expect(compiled.sql).toContain('select * from "sales"."Order"');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('applies filter', () => {
|
|
75
|
-
const builder = createBuilder().filter({ status: 'active' });
|
|
76
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
77
|
-
expect(compiled.sql).toContain('"status" = $1');
|
|
78
|
-
expect(compiled.parameters).toContain('active');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('applies multiple filters (chained)', () => {
|
|
82
|
-
const builder = createBuilder()
|
|
83
|
-
.filter({ status: 'active' })
|
|
84
|
-
.filter({ total: { gt: 100 } });
|
|
85
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
86
|
-
expect(compiled.sql).toContain('"status" = $1');
|
|
87
|
-
expect(compiled.sql).toContain('"total" > $2');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('applies sort ascending by default', () => {
|
|
91
|
-
const builder = createBuilder().sort('name');
|
|
92
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
93
|
-
expect(compiled.sql).toContain('order by "name" asc');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('applies sort descending', () => {
|
|
97
|
-
const builder = createBuilder().sort('created_at', 'desc');
|
|
98
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
99
|
-
expect(compiled.sql).toContain('order by "created_at" desc');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('applies multiple sorts in order', () => {
|
|
103
|
-
const builder = createBuilder().sort('status', 'asc').sort('created_at', 'desc');
|
|
104
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
105
|
-
expect(compiled.sql).toContain('order by "status" asc, "created_at" desc');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('applies limit', () => {
|
|
109
|
-
const builder = createBuilder().limit(10);
|
|
110
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
111
|
-
expect(compiled.sql).toContain('limit $1');
|
|
112
|
-
expect(compiled.parameters).toContain(10);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('applies offset', () => {
|
|
116
|
-
const builder = createBuilder().offset(20);
|
|
117
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
118
|
-
expect(compiled.sql).toContain('offset $1');
|
|
119
|
-
expect(compiled.parameters).toContain(20);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('applies limit and offset together', () => {
|
|
123
|
-
const builder = createBuilder().limit(10).offset(20);
|
|
124
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
125
|
-
expect(compiled.sql).toContain('limit $1');
|
|
126
|
-
expect(compiled.sql).toContain('offset $2');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('applies field selection', () => {
|
|
130
|
-
const builder = createBuilder().fields(['id', 'name', 'status']);
|
|
131
|
-
const compiled = builder.compile() as { sql: string; parameters: unknown[] };
|
|
132
|
-
expect(compiled.sql).toContain('"id"');
|
|
133
|
-
expect(compiled.sql).toContain('"name"');
|
|
134
|
-
expect(compiled.sql).toContain('"status"');
|
|
135
|
-
expect(compiled.sql).not.toContain('*');
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe('chaining order independence', () => {
|
|
140
|
-
it('produces same SQL regardless of method order', () => {
|
|
141
|
-
const a = createBuilder()
|
|
142
|
-
.filter({ status: 'active' })
|
|
143
|
-
.sort('name')
|
|
144
|
-
.limit(10)
|
|
145
|
-
.offset(5)
|
|
146
|
-
.compile() as { sql: string };
|
|
147
|
-
|
|
148
|
-
const b = createBuilder()
|
|
149
|
-
.limit(10)
|
|
150
|
-
.offset(5)
|
|
151
|
-
.sort('name')
|
|
152
|
-
.filter({ status: 'active' })
|
|
153
|
-
.compile() as { sql: string };
|
|
154
|
-
|
|
155
|
-
expect(a.sql).toBe(b.sql);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('count compilation', () => {
|
|
160
|
-
it('generates count query', () => {
|
|
161
|
-
const builder = createBuilder().filter({ status: 'active' });
|
|
162
|
-
const compiled = builder.compileCount() as { sql: string; parameters: unknown[] };
|
|
163
|
-
expect(compiled.sql).toContain('count(*)');
|
|
164
|
-
expect(compiled.sql).toContain('"status" = $1');
|
|
165
|
-
expect(compiled.sql).not.toContain('limit');
|
|
166
|
-
expect(compiled.sql).not.toContain('offset');
|
|
167
|
-
expect(compiled.sql).not.toContain('order by');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe('immutability', () => {
|
|
172
|
-
it('each chain call returns a new instance', () => {
|
|
173
|
-
const base = createBuilder();
|
|
174
|
-
const filtered = base.filter({ status: 'active' });
|
|
175
|
-
const sorted = base.sort('name');
|
|
176
|
-
|
|
177
|
-
expect(filtered).not.toBe(base);
|
|
178
|
-
expect(sorted).not.toBe(base);
|
|
179
|
-
expect(filtered).not.toBe(sorted);
|
|
180
|
-
|
|
181
|
-
const filteredSql = (filtered.compile() as { sql: string }).sql;
|
|
182
|
-
const sortedSql = (sorted.compile() as { sql: string }).sql;
|
|
183
|
-
expect(filteredSql).toContain('"status"');
|
|
184
|
-
expect(filteredSql).not.toContain('order by');
|
|
185
|
-
expect(sortedSql).toContain('order by');
|
|
186
|
-
expect(sortedSql).not.toContain('"status"');
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe('unscoped', () => {
|
|
191
|
-
it('sets unscoped flag', () => {
|
|
192
|
-
const builder = createBuilder().unscoped();
|
|
193
|
-
expect(builder.isUnscoped()).toBe(true);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('defaults to scoped', () => {
|
|
197
|
-
const builder = createBuilder();
|
|
198
|
-
expect(builder.isUnscoped()).toBe(false);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe('include', () => {
|
|
203
|
-
it('stores include relations', () => {
|
|
204
|
-
const builder = createBuilder().include('customer').include('lineItems');
|
|
205
|
-
expect(builder.getIncludes()).toEqual(['customer', 'lineItems']);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('deduplicates includes', () => {
|
|
209
|
-
const builder = createBuilder().include('customer').include('customer');
|
|
210
|
-
expect(builder.getIncludes()).toEqual(['customer']);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe('soft_delete model', () => {
|
|
215
|
-
it('adds archived_at IS NULL filter by default', () => {
|
|
216
|
-
const softModel = makeModel('sales.Order', ['soft_delete']);
|
|
217
|
-
const softRegistry = makeRegistry([softModel]);
|
|
218
|
-
const softOps = new KyselyModelOps({ db, model: softModel, registry: softRegistry });
|
|
219
|
-
const builder = new ModelQueryBuilder(softOps, softModel, softRegistry);
|
|
220
|
-
const compiled = builder.compile() as { sql: string };
|
|
221
|
-
expect(compiled.sql).toContain('"archived_at" is null');
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
});
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
Kysely,
|
|
4
|
-
DummyDriver,
|
|
5
|
-
PostgresAdapter,
|
|
6
|
-
PostgresIntrospector,
|
|
7
|
-
PostgresQueryCompiler,
|
|
8
|
-
} from 'kysely';
|
|
9
|
-
import { applyScopeEnforcement, stripHiddenFields, enforceReadOnly } from '../scope-enforcer.js';
|
|
10
|
-
import type { ResolvedModel } from '../../schema/types.js';
|
|
11
|
-
import type { RequestContext, ScopeFilter } from '../../auth/types.js';
|
|
12
|
-
|
|
13
|
-
function createTestDb() {
|
|
14
|
-
return new Kysely<any>({
|
|
15
|
-
dialect: {
|
|
16
|
-
createAdapter: () => new PostgresAdapter(),
|
|
17
|
-
createDriver: () => new DummyDriver(),
|
|
18
|
-
createIntrospector: (db: any) => new PostgresIntrospector(db),
|
|
19
|
-
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
20
|
-
},
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function makeModel(qualifiedName: string, traits: string[] = []): ResolvedModel {
|
|
25
|
-
return {
|
|
26
|
-
qualifiedName,
|
|
27
|
-
app: 'test',
|
|
28
|
-
module: qualifiedName.split('.')[0],
|
|
29
|
-
name: qualifiedName.split('.')[1],
|
|
30
|
-
auditLog: false,
|
|
31
|
-
traits,
|
|
32
|
-
fields: [
|
|
33
|
-
{ name: 'id', config: { type: 'uuid' }, provenance: { source: 'base' } },
|
|
34
|
-
{ name: 'name', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
35
|
-
{ name: 'secret_field', config: { type: 'string' }, provenance: { source: 'base' } },
|
|
36
|
-
{ name: 'tenant_id', config: { type: 'uuid' }, provenance: { source: 'base' } },
|
|
37
|
-
{
|
|
38
|
-
name: 'created_by',
|
|
39
|
-
config: { type: 'uuid' },
|
|
40
|
-
provenance: { source: 'trait', trait: 'timestamped' },
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
indexes: [],
|
|
44
|
-
} as ResolvedModel;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe('applyScopeEnforcement', () => {
|
|
48
|
-
const db = createTestDb();
|
|
49
|
-
|
|
50
|
-
it('adds scope filter WHERE clauses to query', () => {
|
|
51
|
-
const scopeFilters: ScopeFilter[] = [{ field: 'tenant_id', operator: 'eq', value: 'tenant-1' }];
|
|
52
|
-
const auth: RequestContext = { scopeFilters };
|
|
53
|
-
|
|
54
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
55
|
-
query = applyScopeEnforcement(query, auth);
|
|
56
|
-
const compiled = query.compile();
|
|
57
|
-
|
|
58
|
-
expect(compiled.sql).toContain('"tenant_id" = $1');
|
|
59
|
-
expect(compiled.parameters).toEqual(['tenant-1']);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('adds multiple scope filters as AND conditions', () => {
|
|
63
|
-
const scopeFilters: ScopeFilter[] = [
|
|
64
|
-
{ field: 'tenant_id', operator: 'eq', value: 'tenant-1' },
|
|
65
|
-
{ field: 'branch_id', operator: 'eq', value: 'branch-1' },
|
|
66
|
-
];
|
|
67
|
-
const auth: RequestContext = { scopeFilters };
|
|
68
|
-
|
|
69
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
70
|
-
query = applyScopeEnforcement(query, auth);
|
|
71
|
-
const compiled = query.compile();
|
|
72
|
-
|
|
73
|
-
expect(compiled.sql).toContain('"tenant_id" = $1');
|
|
74
|
-
expect(compiled.sql).toContain('"branch_id" = $2');
|
|
75
|
-
expect(compiled.parameters).toEqual(['tenant-1', 'branch-1']);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('handles "in" operator for scope filters', () => {
|
|
79
|
-
const scopeFilters: ScopeFilter[] = [
|
|
80
|
-
{ field: 'tenant_id', operator: 'in', value: ['t-1', 't-2'] },
|
|
81
|
-
];
|
|
82
|
-
const auth: RequestContext = { scopeFilters };
|
|
83
|
-
|
|
84
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
85
|
-
query = applyScopeEnforcement(query, auth);
|
|
86
|
-
const compiled = query.compile();
|
|
87
|
-
|
|
88
|
-
expect(compiled.sql).toContain('"tenant_id" in ($1, $2)');
|
|
89
|
-
expect(compiled.parameters).toEqual(['t-1', 't-2']);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('does nothing when no scope filters exist', () => {
|
|
93
|
-
const auth: RequestContext = {};
|
|
94
|
-
|
|
95
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
96
|
-
query = applyScopeEnforcement(query, auth);
|
|
97
|
-
const compiled = query.compile();
|
|
98
|
-
|
|
99
|
-
expect(compiled.sql).not.toContain('where');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('does nothing when scopeFilters is empty array', () => {
|
|
103
|
-
const auth: RequestContext = { scopeFilters: [] };
|
|
104
|
-
|
|
105
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
106
|
-
query = applyScopeEnforcement(query, auth);
|
|
107
|
-
const compiled = query.compile();
|
|
108
|
-
|
|
109
|
-
expect(compiled.sql).not.toContain('where');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('does nothing when auth is undefined', () => {
|
|
113
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
114
|
-
query = applyScopeEnforcement(query, undefined);
|
|
115
|
-
const compiled = query.compile();
|
|
116
|
-
|
|
117
|
-
expect(compiled.sql).not.toContain('where');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('adds owner-only filter when specified', () => {
|
|
121
|
-
const auth: RequestContext = {
|
|
122
|
-
user: { id: 'user-1', email: 'a@b.c', full_name: 'A', enabled: true, password_hash: '' },
|
|
123
|
-
permissions: {
|
|
124
|
-
models: {
|
|
125
|
-
'sales.Order': { read: 'own', write: 'own', delete: 'own' },
|
|
126
|
-
},
|
|
127
|
-
pages: [],
|
|
128
|
-
version: 1,
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const model = makeModel('sales.Order');
|
|
133
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
134
|
-
query = applyScopeEnforcement(query, auth, { model, checkOwnership: true });
|
|
135
|
-
const compiled = query.compile();
|
|
136
|
-
|
|
137
|
-
expect(compiled.sql).toContain('"created_by" = $1');
|
|
138
|
-
expect(compiled.parameters).toEqual(['user-1']);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('does not add owner filter when permission is not "own"', () => {
|
|
142
|
-
const auth: RequestContext = {
|
|
143
|
-
user: { id: 'user-1', email: 'a@b.c', full_name: 'A', enabled: true, password_hash: '' },
|
|
144
|
-
permissions: {
|
|
145
|
-
models: {
|
|
146
|
-
'sales.Order': { read: true, write: true, delete: true },
|
|
147
|
-
},
|
|
148
|
-
pages: [],
|
|
149
|
-
version: 1,
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const model = makeModel('sales.Order');
|
|
154
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
155
|
-
query = applyScopeEnforcement(query, auth, { model, checkOwnership: true });
|
|
156
|
-
const compiled = query.compile();
|
|
157
|
-
|
|
158
|
-
expect(compiled.sql).not.toContain('"created_by"');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('does not add owner filter when model lacks created_by field', () => {
|
|
162
|
-
const auth: RequestContext = {
|
|
163
|
-
user: { id: 'user-1', email: 'a@b.c', full_name: 'A', enabled: true, password_hash: '' },
|
|
164
|
-
permissions: {
|
|
165
|
-
models: {
|
|
166
|
-
'sales.Order': { read: 'own', write: 'own', delete: 'own' },
|
|
167
|
-
},
|
|
168
|
-
pages: [],
|
|
169
|
-
version: 1,
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const modelWithoutCreatedBy = {
|
|
174
|
-
qualifiedName: 'sales.Order',
|
|
175
|
-
app: 'test',
|
|
176
|
-
module: 'sales',
|
|
177
|
-
name: 'Order',
|
|
178
|
-
auditLog: false,
|
|
179
|
-
traits: [],
|
|
180
|
-
fields: [{ name: 'id', config: { type: 'string' }, provenance: { source: 'base' } }],
|
|
181
|
-
indexes: [],
|
|
182
|
-
} as ResolvedModel;
|
|
183
|
-
|
|
184
|
-
let query = db.selectFrom('sales__Order').selectAll();
|
|
185
|
-
query = applyScopeEnforcement(query, auth, {
|
|
186
|
-
model: modelWithoutCreatedBy,
|
|
187
|
-
checkOwnership: true,
|
|
188
|
-
});
|
|
189
|
-
const compiled = query.compile();
|
|
190
|
-
|
|
191
|
-
expect(compiled.sql).not.toContain('"created_by"');
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe('stripHiddenFields', () => {
|
|
196
|
-
it('removes hidden fields from a single record', () => {
|
|
197
|
-
const record = { id: '1', name: 'Test', secret_field: 'secret' };
|
|
198
|
-
const hidden = new Set(['secret_field']);
|
|
199
|
-
const result = stripHiddenFields(record, hidden);
|
|
200
|
-
expect(result).toEqual({ id: '1', name: 'Test' });
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('removes hidden fields from array of records', () => {
|
|
204
|
-
const records = [
|
|
205
|
-
{ id: '1', name: 'A', secret_field: 'x' },
|
|
206
|
-
{ id: '2', name: 'B', secret_field: 'y' },
|
|
207
|
-
];
|
|
208
|
-
const hidden = new Set(['secret_field']);
|
|
209
|
-
const result = records.map((r) => stripHiddenFields(r, hidden));
|
|
210
|
-
expect(result).toEqual([
|
|
211
|
-
{ id: '1', name: 'A' },
|
|
212
|
-
{ id: '2', name: 'B' },
|
|
213
|
-
]);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('returns record unchanged when no fields are hidden', () => {
|
|
217
|
-
const record = { id: '1', name: 'Test' };
|
|
218
|
-
const hidden = new Set<string>();
|
|
219
|
-
const result = stripHiddenFields(record, hidden);
|
|
220
|
-
expect(result).toEqual(record);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('returns empty object when all fields are hidden', () => {
|
|
224
|
-
const record = { id: '1', name: 'Test' };
|
|
225
|
-
const hidden = new Set(['id', 'name']);
|
|
226
|
-
const result = stripHiddenFields(record, hidden);
|
|
227
|
-
expect(result).toEqual({});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('handles record with nested objects (does not strip nested)', () => {
|
|
231
|
-
const record = { id: '1', meta: { secret_field: 'x' }, secret_field: 'top' };
|
|
232
|
-
const hidden = new Set(['secret_field']);
|
|
233
|
-
const result = stripHiddenFields(record, hidden);
|
|
234
|
-
expect(result).toEqual({ id: '1', meta: { secret_field: 'x' } });
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe('enforceReadOnly', () => {
|
|
239
|
-
it('throws when writing to a read-only field', () => {
|
|
240
|
-
const readOnly = new Set(['status']);
|
|
241
|
-
const data = { status: 'active', name: 'Test' };
|
|
242
|
-
expect(() => enforceReadOnly(data, readOnly)).toThrow(/read-only.*status/i);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('does not throw when writing to non-read-only fields', () => {
|
|
246
|
-
const readOnly = new Set(['status']);
|
|
247
|
-
const data = { name: 'Test', email: 'a@b.c' };
|
|
248
|
-
expect(() => enforceReadOnly(data, readOnly)).not.toThrow();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('does not throw when readOnly set is empty', () => {
|
|
252
|
-
const readOnly = new Set<string>();
|
|
253
|
-
const data = { status: 'active', name: 'Test' };
|
|
254
|
-
expect(() => enforceReadOnly(data, readOnly)).not.toThrow();
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('reports all violated fields in the error', () => {
|
|
258
|
-
const readOnly = new Set(['status', 'level']);
|
|
259
|
-
const data = { status: 'active', level: 5, name: 'Test' };
|
|
260
|
-
expect(() => enforceReadOnly(data, readOnly)).toThrow(/status.*level|level.*status/);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('ignores undefined values (not actually writing)', () => {
|
|
264
|
-
const readOnly = new Set(['status']);
|
|
265
|
-
const data = { status: undefined, name: 'Test' };
|
|
266
|
-
expect(() => enforceReadOnly(data, readOnly)).not.toThrow();
|
|
267
|
-
});
|
|
268
|
-
});
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
export function stripHiddenFields(
|
|
2
|
-
record: Record<string, unknown>,
|
|
3
|
-
hidden: Set<string>,
|
|
4
|
-
): Record<string, unknown> {
|
|
5
|
-
if (hidden.size === 0) return record;
|
|
6
|
-
const result: Record<string, unknown> = {};
|
|
7
|
-
for (const [key, value] of Object.entries(record)) {
|
|
8
|
-
if (!hidden.has(key)) {
|
|
9
|
-
result[key] = value;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
return result;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function enforceReadOnly(data: Record<string, unknown>, readOnly: Set<string>): void {
|
|
16
|
-
if (readOnly.size === 0) return;
|
|
17
|
-
|
|
18
|
-
const violated: string[] = [];
|
|
19
|
-
for (const field of readOnly) {
|
|
20
|
-
if (field in data && data[field] !== undefined) {
|
|
21
|
-
violated.push(field);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (violated.length > 0) {
|
|
26
|
-
throw new Error(`Cannot write to read-only fields: ${violated.join(', ')}`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { applyModelFilters } from '../db/filter-applier.js';
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import type { FilterExpression, FilterOperators, TranslatedFilter } from './types.js';
|
|
2
|
-
import { isNil } from '../helpers/coerce.js';
|
|
3
|
-
|
|
4
|
-
export type { TranslatedFilter } from './types.js';
|
|
5
|
-
|
|
6
|
-
const VALID_OPERATORS = new Set([
|
|
7
|
-
'eq',
|
|
8
|
-
'neq',
|
|
9
|
-
'gt',
|
|
10
|
-
'gte',
|
|
11
|
-
'lt',
|
|
12
|
-
'lte',
|
|
13
|
-
'in',
|
|
14
|
-
'notIn',
|
|
15
|
-
'contains',
|
|
16
|
-
'startsWith',
|
|
17
|
-
'endsWith',
|
|
18
|
-
'is',
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
function isOperatorObject(value: unknown): value is FilterOperators {
|
|
22
|
-
if (isNil(value)) return false;
|
|
23
|
-
if (typeof value !== 'object') return false;
|
|
24
|
-
if (Array.isArray(value)) return false;
|
|
25
|
-
if (value instanceof Date) return false;
|
|
26
|
-
const keys = Object.keys(value);
|
|
27
|
-
if (keys.length === 0) return false;
|
|
28
|
-
const hasAnyKnown = keys.some((k) => VALID_OPERATORS.has(k));
|
|
29
|
-
const hasAnyUnknown = keys.some((k) => !VALID_OPERATORS.has(k));
|
|
30
|
-
if (hasAnyUnknown && !hasAnyKnown) {
|
|
31
|
-
throw new Error(`Unknown filter operator: ${keys.find((k) => !VALID_OPERATORS.has(k))}`);
|
|
32
|
-
}
|
|
33
|
-
if (hasAnyUnknown && hasAnyKnown) {
|
|
34
|
-
throw new Error(`Unknown filter operator: ${keys.find((k) => !VALID_OPERATORS.has(k))}`);
|
|
35
|
-
}
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function translateFilters(
|
|
40
|
-
expression: FilterExpression | null | undefined,
|
|
41
|
-
): TranslatedFilter[] {
|
|
42
|
-
if (!expression || typeof expression !== 'object') return [];
|
|
43
|
-
|
|
44
|
-
const results: TranslatedFilter[] = [];
|
|
45
|
-
|
|
46
|
-
for (const [field, value] of Object.entries(expression)) {
|
|
47
|
-
if (value === null) {
|
|
48
|
-
results.push({ field, operator: 'is', value: null });
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (isOperatorObject(value)) {
|
|
53
|
-
const ops = value as Record<string, unknown>;
|
|
54
|
-
for (const [op, opValue] of Object.entries(ops)) {
|
|
55
|
-
if (!VALID_OPERATORS.has(op)) {
|
|
56
|
-
throw new Error(`Unknown filter operator: ${op}`);
|
|
57
|
-
}
|
|
58
|
-
results.push({ field, operator: op, value: opValue });
|
|
59
|
-
}
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
results.push({ field, operator: 'eq', value });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return results;
|
|
67
|
-
}
|
package/src/model-api/index.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import type { ModelAccess, ModelAccessOptions, ModelOps, ModelQuery } from './types.js';
|
|
2
|
-
import { ModelQueryBuilder } from './query-builder.js';
|
|
3
|
-
import { KyselyModelOps } from '../db/model-ops.js';
|
|
4
|
-
import { ExternalModelOps } from '../external-model/external-model-ops.js';
|
|
5
|
-
import { CompositeIncludeResolver } from '../db/include-resolver.js';
|
|
6
|
-
|
|
7
|
-
export type {
|
|
8
|
-
ModelAccess,
|
|
9
|
-
ModelQuery,
|
|
10
|
-
ModelAccessOptions,
|
|
11
|
-
ModelOps,
|
|
12
|
-
FilterExpression,
|
|
13
|
-
QueryResult,
|
|
14
|
-
QueryResultWithMeta,
|
|
15
|
-
QueryState,
|
|
16
|
-
TranslatedFilter,
|
|
17
|
-
IncludeResolver,
|
|
18
|
-
} from './types.js';
|
|
19
|
-
export { ModelQueryBuilder } from './query-builder.js';
|
|
20
|
-
export { translateFilters } from './filter-translator.js';
|
|
21
|
-
export { applyModelFilters } from './filter-applier.js';
|
|
22
|
-
export { applyScopeEnforcement, stripHiddenFields, enforceReadOnly } from './scope-enforcer.js';
|
|
23
|
-
export { resolveModelIncludes } from './include-resolver.js';
|
|
24
|
-
export { CapabilityNotSupportedError } from '../external-model/mutation-executor.js';
|
|
25
|
-
|
|
26
|
-
export function createModelAccess(opts: ModelAccessOptions): ModelAccess {
|
|
27
|
-
const { db, registry, auth, adapterRegistry, externalModelFields, adapterCapabilities } = opts;
|
|
28
|
-
|
|
29
|
-
const includeResolver = new CompositeIncludeResolver({
|
|
30
|
-
registry,
|
|
31
|
-
db,
|
|
32
|
-
adapterRegistry,
|
|
33
|
-
externalModelFields,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
function resolveOps(modelName: string): ModelOps {
|
|
37
|
-
const model = registry.getModel(modelName);
|
|
38
|
-
if (!model) throw new Error(`Model not found: ${modelName}`);
|
|
39
|
-
|
|
40
|
-
if (model.source) {
|
|
41
|
-
if (!adapterRegistry) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
`No adapter registry configured. Cannot resolve external model: ${modelName}`,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
const adapter = adapterRegistry.get(model.source);
|
|
47
|
-
const fields = externalModelFields?.[model.qualifiedName] ?? {};
|
|
48
|
-
const capabilities = adapterCapabilities?.[model.source] ?? ['read'];
|
|
49
|
-
return new ExternalModelOps({
|
|
50
|
-
adapter,
|
|
51
|
-
adapterName: model.source,
|
|
52
|
-
modelName: model.qualifiedName,
|
|
53
|
-
fields,
|
|
54
|
-
capabilities,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return new KyselyModelOps({ db, model, registry, auth });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
async get(modelName: string, id: string) {
|
|
63
|
-
return resolveOps(modelName).get(id);
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
query(modelName: string): ModelQuery {
|
|
67
|
-
const model = registry.getModel(modelName);
|
|
68
|
-
if (!model) throw new Error(`Model not found: ${modelName}`);
|
|
69
|
-
const ops = resolveOps(modelName);
|
|
70
|
-
const qb = new ModelQueryBuilder(ops, model, registry, includeResolver);
|
|
71
|
-
return auth ? qb.withAuth(auth) : qb;
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
async create(modelName: string, data: Record<string, unknown>) {
|
|
75
|
-
return resolveOps(modelName).create(data, auth);
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
async update(modelName: string, id: string, data: Record<string, unknown>) {
|
|
79
|
-
return resolveOps(modelName).update(id, data, auth);
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
async delete(modelName: string, id: string) {
|
|
83
|
-
return resolveOps(modelName).delete(id, auth);
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|