@rangka/core 0.1.1 → 0.1.3
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 -25
- 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,175 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
ServiceRegistry,
|
|
4
|
-
ServiceCircularDependencyError,
|
|
5
|
-
ServiceNotFoundError,
|
|
6
|
-
DuplicateServiceError,
|
|
7
|
-
} from '../registry.js';
|
|
8
|
-
import type { ServiceDefinition, ServiceContext } from '../types.js';
|
|
9
|
-
|
|
10
|
-
const baseContext: Omit<ServiceContext, 'service'> = {
|
|
11
|
-
db: {} as ServiceContext['db'],
|
|
12
|
-
schema: {} as ServiceContext['schema'],
|
|
13
|
-
enqueue: async () => {},
|
|
14
|
-
events: { emit: async () => {}, on: () => {} },
|
|
15
|
-
config: {},
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
describe('ServiceRegistry', () => {
|
|
19
|
-
it('registers and retrieves a service', () => {
|
|
20
|
-
const registry = new ServiceRegistry();
|
|
21
|
-
const def: ServiceDefinition = {
|
|
22
|
-
name: 'notifications',
|
|
23
|
-
factory: () => ({
|
|
24
|
-
send: async (msg: unknown) => msg,
|
|
25
|
-
}),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
registry.register(def);
|
|
29
|
-
expect(registry.has('notifications')).toBe(true);
|
|
30
|
-
|
|
31
|
-
const instance = registry.get('notifications', baseContext);
|
|
32
|
-
expect(instance.send).toBeTypeOf('function');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('returns the same instance on repeated get (lazy singleton)', () => {
|
|
36
|
-
const registry = new ServiceRegistry();
|
|
37
|
-
let callCount = 0;
|
|
38
|
-
registry.register({
|
|
39
|
-
name: 'counter',
|
|
40
|
-
factory: () => {
|
|
41
|
-
callCount++;
|
|
42
|
-
return { count: () => callCount };
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const a = registry.get('counter', baseContext);
|
|
47
|
-
const b = registry.get('counter', baseContext);
|
|
48
|
-
expect(a).toBe(b);
|
|
49
|
-
expect(callCount).toBe(1);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('throws on duplicate registration', () => {
|
|
53
|
-
const registry = new ServiceRegistry();
|
|
54
|
-
registry.register({ name: 'svc', factory: () => ({}) });
|
|
55
|
-
|
|
56
|
-
expect(() => registry.register({ name: 'svc', factory: () => ({}) })).toThrow(
|
|
57
|
-
DuplicateServiceError,
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('throws when getting an unregistered service', () => {
|
|
62
|
-
const registry = new ServiceRegistry();
|
|
63
|
-
|
|
64
|
-
expect(() => registry.get('nonexistent', baseContext)).toThrow(ServiceNotFoundError);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('detects circular dependencies at registration time', () => {
|
|
68
|
-
const registry = new ServiceRegistry();
|
|
69
|
-
registry.register({ name: 'a', deps: ['b'], factory: () => ({}) });
|
|
70
|
-
registry.register({ name: 'b', deps: ['c'], factory: () => ({}) });
|
|
71
|
-
registry.register({ name: 'c', deps: ['a'], factory: () => ({}) });
|
|
72
|
-
|
|
73
|
-
expect(() => registry.detectCircularDependencies()).toThrow(ServiceCircularDependencyError);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('does not throw for valid dependency graph', () => {
|
|
77
|
-
const registry = new ServiceRegistry();
|
|
78
|
-
registry.register({ name: 'a', deps: ['b'], factory: () => ({}) });
|
|
79
|
-
registry.register({ name: 'b', deps: ['c'], factory: () => ({}) });
|
|
80
|
-
registry.register({ name: 'c', factory: () => ({}) });
|
|
81
|
-
|
|
82
|
-
expect(() => registry.detectCircularDependencies()).not.toThrow();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('injects dependencies via service() in context', () => {
|
|
86
|
-
const registry = new ServiceRegistry();
|
|
87
|
-
registry.register({
|
|
88
|
-
name: 'logger',
|
|
89
|
-
factory: () => ({ log: (msg: unknown) => `logged: ${msg}` }),
|
|
90
|
-
});
|
|
91
|
-
registry.register({
|
|
92
|
-
name: 'mailer',
|
|
93
|
-
deps: ['logger'],
|
|
94
|
-
factory: (ctx: ServiceContext) => ({
|
|
95
|
-
send: (to: unknown) => {
|
|
96
|
-
const logger = ctx.service('logger');
|
|
97
|
-
return (logger as any).log(`email to ${to}`);
|
|
98
|
-
},
|
|
99
|
-
}),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const mailer = registry.get('mailer', baseContext);
|
|
103
|
-
expect(mailer.send('user@test.com')).toBe('logged: email to user@test.com');
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('resolves deep dependency chains', () => {
|
|
107
|
-
const registry = new ServiceRegistry();
|
|
108
|
-
registry.register({
|
|
109
|
-
name: 'c',
|
|
110
|
-
factory: () => ({ value: () => 42 }),
|
|
111
|
-
});
|
|
112
|
-
registry.register({
|
|
113
|
-
name: 'b',
|
|
114
|
-
deps: ['c'],
|
|
115
|
-
factory: (ctx) => ({ value: () => (ctx.service('c') as any).value() + 1 }),
|
|
116
|
-
});
|
|
117
|
-
registry.register({
|
|
118
|
-
name: 'a',
|
|
119
|
-
deps: ['b'],
|
|
120
|
-
factory: (ctx) => ({ value: () => (ctx.service('b') as any).value() + 1 }),
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
const a = registry.get('a', baseContext);
|
|
124
|
-
expect(a.value()).toBe(44);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('detects circular dependency at runtime during resolution', () => {
|
|
128
|
-
const registry = new ServiceRegistry();
|
|
129
|
-
registry.register({
|
|
130
|
-
name: 'x',
|
|
131
|
-
deps: ['y'],
|
|
132
|
-
factory: (ctx) => {
|
|
133
|
-
ctx.service('y');
|
|
134
|
-
return {};
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
registry.register({
|
|
138
|
-
name: 'y',
|
|
139
|
-
deps: ['x'],
|
|
140
|
-
factory: (ctx) => {
|
|
141
|
-
ctx.service('x');
|
|
142
|
-
return {};
|
|
143
|
-
},
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(() => registry.get('x', baseContext)).toThrow(ServiceCircularDependencyError);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('reset clears cached instances', () => {
|
|
150
|
-
const registry = new ServiceRegistry();
|
|
151
|
-
let callCount = 0;
|
|
152
|
-
registry.register({
|
|
153
|
-
name: 'svc',
|
|
154
|
-
factory: () => {
|
|
155
|
-
callCount++;
|
|
156
|
-
return {};
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
registry.get('svc', baseContext);
|
|
161
|
-
expect(callCount).toBe(1);
|
|
162
|
-
|
|
163
|
-
registry.reset();
|
|
164
|
-
registry.get('svc', baseContext);
|
|
165
|
-
expect(callCount).toBe(2);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('getAll returns all definitions', () => {
|
|
169
|
-
const registry = new ServiceRegistry();
|
|
170
|
-
registry.register({ name: 'a', factory: () => ({}) });
|
|
171
|
-
registry.register({ name: 'b', factory: () => ({}) });
|
|
172
|
-
|
|
173
|
-
expect(registry.getAll()).toHaveLength(2);
|
|
174
|
-
});
|
|
175
|
-
});
|
package/src/services/index.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
ServiceRegistry,
|
|
3
|
-
ServiceCircularDependencyError,
|
|
4
|
-
ServiceNotFoundError,
|
|
5
|
-
DuplicateServiceError,
|
|
6
|
-
} from './registry.js';
|
|
7
|
-
export type {
|
|
8
|
-
ServiceDefinition,
|
|
9
|
-
ServiceFactory,
|
|
10
|
-
ServiceInstance,
|
|
11
|
-
ServiceDependency,
|
|
12
|
-
ServiceContext,
|
|
13
|
-
} from './types.js';
|
package/src/services/registry.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import type { ServiceDefinition, ServiceInstance, ServiceContext } from './types.js';
|
|
2
|
-
|
|
3
|
-
// --- Error types ---
|
|
4
|
-
|
|
5
|
-
export class ServiceCircularDependencyError extends Error {
|
|
6
|
-
constructor(public readonly cycle: string[]) {
|
|
7
|
-
super(`Circular service dependency detected: ${cycle.join(' → ')}`);
|
|
8
|
-
this.name = 'ServiceCircularDependencyError';
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export class ServiceNotFoundError extends Error {
|
|
13
|
-
constructor(public readonly serviceName: string) {
|
|
14
|
-
super(`Service "${serviceName}" is not registered`);
|
|
15
|
-
this.name = 'ServiceNotFoundError';
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export class DuplicateServiceError extends Error {
|
|
20
|
-
constructor(public readonly serviceName: string) {
|
|
21
|
-
super(`Service "${serviceName}" is already registered`);
|
|
22
|
-
this.name = 'DuplicateServiceError';
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// --- Registry ---
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Manages service definitions and lazily-created singleton instances.
|
|
30
|
-
* Services declare dependencies by name; the registry resolves them on first access.
|
|
31
|
-
*/
|
|
32
|
-
export class ServiceRegistry {
|
|
33
|
-
/** Registered service blueprints (name -> definition). */
|
|
34
|
-
private definitions = new Map<string, ServiceDefinition>();
|
|
35
|
-
|
|
36
|
-
/** Cached singleton instances, created on first `get()` call. */
|
|
37
|
-
private instances = new Map<string, ServiceInstance>();
|
|
38
|
-
|
|
39
|
-
/** Tracks which services are mid-resolution to detect runtime cycles. */
|
|
40
|
-
private resolving = new Set<string>();
|
|
41
|
-
|
|
42
|
-
// --- Registration & lookup ---
|
|
43
|
-
|
|
44
|
-
/** Register a service definition. Throws if the name is already taken. */
|
|
45
|
-
register(definition: ServiceDefinition): void {
|
|
46
|
-
if (this.definitions.has(definition.name)) {
|
|
47
|
-
throw new DuplicateServiceError(definition.name);
|
|
48
|
-
}
|
|
49
|
-
this.definitions.set(definition.name, definition);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Check whether a service name has been registered. */
|
|
53
|
-
has(name: string): boolean {
|
|
54
|
-
return this.definitions.has(name);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Return the raw definition for a service, or undefined if not registered. */
|
|
58
|
-
getDefinition(name: string): ServiceDefinition | undefined {
|
|
59
|
-
return this.definitions.get(name);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Return all registered definitions. */
|
|
63
|
-
getAll(): ServiceDefinition[] {
|
|
64
|
-
return [...this.definitions.values()];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// --- Dependency validation ---
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Walk the full dependency graph up-front and throw if any cycle exists.
|
|
71
|
-
* Uses depth-first traversal with a recursion stack to detect back-edges.
|
|
72
|
-
*/
|
|
73
|
-
detectCircularDependencies(): void {
|
|
74
|
-
const fullyVisited = new Set<string>();
|
|
75
|
-
const currentPath = new Set<string>();
|
|
76
|
-
|
|
77
|
-
const visit = (serviceName: string, ancestors: string[]): void => {
|
|
78
|
-
// Back-edge: we've looped back to a node already on the current path.
|
|
79
|
-
if (currentPath.has(serviceName)) {
|
|
80
|
-
const cycleStart = ancestors.indexOf(serviceName);
|
|
81
|
-
throw new ServiceCircularDependencyError([...ancestors.slice(cycleStart), serviceName]);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Already fully explored from a previous traversal root -- skip.
|
|
85
|
-
if (fullyVisited.has(serviceName)) return;
|
|
86
|
-
|
|
87
|
-
currentPath.add(serviceName);
|
|
88
|
-
ancestors.push(serviceName);
|
|
89
|
-
|
|
90
|
-
const definition = this.definitions.get(serviceName);
|
|
91
|
-
if (definition?.deps) {
|
|
92
|
-
for (const dependency of definition.deps) {
|
|
93
|
-
visit(dependency, ancestors);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
ancestors.pop();
|
|
98
|
-
currentPath.delete(serviceName);
|
|
99
|
-
fullyVisited.add(serviceName);
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
for (const serviceName of this.definitions.keys()) {
|
|
103
|
-
visit(serviceName, []);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// --- Resolution ---
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Resolve a service by name: return the cached instance or create it via its factory.
|
|
111
|
-
* Dependencies are resolved recursively through the same mechanism.
|
|
112
|
-
*/
|
|
113
|
-
get(name: string, baseContext: Omit<ServiceContext, 'service'>): ServiceInstance {
|
|
114
|
-
// Return cached singleton if already instantiated.
|
|
115
|
-
const cached = this.instances.get(name);
|
|
116
|
-
if (cached) return cached;
|
|
117
|
-
|
|
118
|
-
const definition = this.definitions.get(name);
|
|
119
|
-
if (!definition) {
|
|
120
|
-
throw new ServiceNotFoundError(name);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Guard against runtime circular resolution (e.g. A -> B -> A).
|
|
124
|
-
if (this.resolving.has(name)) {
|
|
125
|
-
throw new ServiceCircularDependencyError([...this.resolving, name]);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
this.resolving.add(name);
|
|
129
|
-
try {
|
|
130
|
-
const instance = this.createInstance(definition, baseContext);
|
|
131
|
-
this.instances.set(name, instance);
|
|
132
|
-
return instance;
|
|
133
|
-
} finally {
|
|
134
|
-
this.resolving.delete(name);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Discard all cached instances (definitions remain registered). */
|
|
139
|
-
reset(): void {
|
|
140
|
-
this.instances.clear();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// --- Private helpers ---
|
|
144
|
-
|
|
145
|
-
/** Invoke a service factory with a context that can resolve sibling services. */
|
|
146
|
-
private createInstance(
|
|
147
|
-
definition: ServiceDefinition,
|
|
148
|
-
baseContext: Omit<ServiceContext, 'service'>,
|
|
149
|
-
): ServiceInstance {
|
|
150
|
-
const context: ServiceContext = {
|
|
151
|
-
...baseContext,
|
|
152
|
-
service: (dependencyName: string) => this.get(dependencyName, baseContext),
|
|
153
|
-
};
|
|
154
|
-
return definition.factory(context);
|
|
155
|
-
}
|
|
156
|
-
}
|
package/src/services/types.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { FrameworkContext, ServiceInstance } from '@rangka/shared';
|
|
2
|
-
|
|
3
|
-
export interface ServiceDependency {
|
|
4
|
-
name: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface ServiceDefinition {
|
|
8
|
-
name: string;
|
|
9
|
-
deps?: string[];
|
|
10
|
-
factory: ServiceFactory;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type ServiceFactory = (ctx: ServiceContext) => ServiceInstance;
|
|
14
|
-
|
|
15
|
-
export type { ServiceInstance };
|
|
16
|
-
|
|
17
|
-
export interface ServiceContext {
|
|
18
|
-
db: FrameworkContext['db'];
|
|
19
|
-
schema: FrameworkContext['schema'];
|
|
20
|
-
enqueue: FrameworkContext['enqueue'];
|
|
21
|
-
events: FrameworkContext['events'];
|
|
22
|
-
config: FrameworkContext['config'];
|
|
23
|
-
models?: FrameworkContext['models'];
|
|
24
|
-
auth?: FrameworkContext['auth'];
|
|
25
|
-
scope?: FrameworkContext['scope'];
|
|
26
|
-
service: (name: string) => ServiceInstance;
|
|
27
|
-
}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validateFields } from '../field-validator.js';
|
|
3
|
-
import type { ResolvedModel, ResolvedField } from '../../schema/types.js';
|
|
4
|
-
import type { FieldConfig } from '@rangka/shared';
|
|
5
|
-
|
|
6
|
-
function makeModel(fields: ResolvedField[]): ResolvedModel {
|
|
7
|
-
return {
|
|
8
|
-
app: 'test',
|
|
9
|
-
module: 'test',
|
|
10
|
-
name: 'item',
|
|
11
|
-
qualifiedName: 'test.item',
|
|
12
|
-
auditLog: false,
|
|
13
|
-
traits: [],
|
|
14
|
-
fields,
|
|
15
|
-
indexes: [],
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function field(name: string, config: Partial<FieldConfig> & { type: string }): ResolvedField {
|
|
20
|
-
return { name, config: config as FieldConfig, provenance: { source: 'base' } };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe('validateFields', () => {
|
|
24
|
-
describe('minLength / maxLength', () => {
|
|
25
|
-
const model = makeModel([
|
|
26
|
-
field('name', { type: 'string', validation: { minLength: 3, maxLength: 50 } }),
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
it('passes when string length is within bounds', () => {
|
|
30
|
-
expect(validateFields(model, { name: 'hello' }, 'create')).toEqual([]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('fails when string is too short', () => {
|
|
34
|
-
const result = validateFields(model, { name: 'ab' }, 'create');
|
|
35
|
-
expect(result).toHaveLength(1);
|
|
36
|
-
expect(result[0].field).toBe('name');
|
|
37
|
-
expect(result[0].rule).toBe('minLength');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('fails when string is too long', () => {
|
|
41
|
-
const result = validateFields(model, { name: 'a'.repeat(51) }, 'create');
|
|
42
|
-
expect(result).toHaveLength(1);
|
|
43
|
-
expect(result[0].field).toBe('name');
|
|
44
|
-
expect(result[0].rule).toBe('maxLength');
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('min / max', () => {
|
|
49
|
-
const model = makeModel([field('quantity', { type: 'int', validation: { min: 0, max: 100 } })]);
|
|
50
|
-
|
|
51
|
-
it('passes when number is within bounds', () => {
|
|
52
|
-
expect(validateFields(model, { quantity: 50 }, 'create')).toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('fails when number is below min', () => {
|
|
56
|
-
const result = validateFields(model, { quantity: -1 }, 'create');
|
|
57
|
-
expect(result).toHaveLength(1);
|
|
58
|
-
expect(result[0].field).toBe('quantity');
|
|
59
|
-
expect(result[0].rule).toBe('min');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('fails when number is above max', () => {
|
|
63
|
-
const result = validateFields(model, { quantity: 101 }, 'create');
|
|
64
|
-
expect(result).toHaveLength(1);
|
|
65
|
-
expect(result[0].field).toBe('quantity');
|
|
66
|
-
expect(result[0].rule).toBe('max');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('passes at exact boundaries', () => {
|
|
70
|
-
expect(validateFields(model, { quantity: 0 }, 'create')).toEqual([]);
|
|
71
|
-
expect(validateFields(model, { quantity: 100 }, 'create')).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe('pattern', () => {
|
|
76
|
-
const model = makeModel([
|
|
77
|
-
field('code', { type: 'string', validation: { pattern: '^[A-Z]{3}-\\d+$' } }),
|
|
78
|
-
]);
|
|
79
|
-
|
|
80
|
-
it('passes when value matches pattern', () => {
|
|
81
|
-
expect(validateFields(model, { code: 'ABC-123' }, 'create')).toEqual([]);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('fails when value does not match pattern', () => {
|
|
85
|
-
const result = validateFields(model, { code: 'abc-123' }, 'create');
|
|
86
|
-
expect(result).toHaveLength(1);
|
|
87
|
-
expect(result[0].field).toBe('code');
|
|
88
|
-
expect(result[0].rule).toBe('pattern');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('format', () => {
|
|
93
|
-
const model = makeModel([
|
|
94
|
-
field('email', { type: 'string', validation: { format: 'email' } }),
|
|
95
|
-
field('website', { type: 'string', validation: { format: 'url' } }),
|
|
96
|
-
field('ref', { type: 'string', validation: { format: 'uuid' } }),
|
|
97
|
-
]);
|
|
98
|
-
|
|
99
|
-
it('passes valid email', () => {
|
|
100
|
-
expect(validateFields(model, { email: 'user@example.com' }, 'create')).toEqual([]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('fails invalid email', () => {
|
|
104
|
-
const result = validateFields(model, { email: 'not-an-email' }, 'create');
|
|
105
|
-
expect(result).toHaveLength(1);
|
|
106
|
-
expect(result[0].rule).toBe('format');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('passes valid url', () => {
|
|
110
|
-
expect(validateFields(model, { website: 'https://example.com' }, 'create')).toEqual([]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('fails invalid url', () => {
|
|
114
|
-
const result = validateFields(model, { website: 'not-a-url' }, 'create');
|
|
115
|
-
expect(result).toHaveLength(1);
|
|
116
|
-
expect(result[0].rule).toBe('format');
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('passes valid uuid', () => {
|
|
120
|
-
expect(
|
|
121
|
-
validateFields(model, { ref: '550e8400-e29b-41d4-a716-446655440000' }, 'create'),
|
|
122
|
-
).toEqual([]);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('fails invalid uuid', () => {
|
|
126
|
-
const result = validateFields(model, { ref: 'not-a-uuid' }, 'create');
|
|
127
|
-
expect(result).toHaveLength(1);
|
|
128
|
-
expect(result[0].rule).toBe('format');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('ignores unknown format', () => {
|
|
132
|
-
const m = makeModel([
|
|
133
|
-
field('x', { type: 'string', validation: { format: 'unknown_format' } }),
|
|
134
|
-
]);
|
|
135
|
-
expect(validateFields(m, { x: 'anything' }, 'create')).toEqual([]);
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe('custom message', () => {
|
|
140
|
-
it('uses custom message when provided', () => {
|
|
141
|
-
const model = makeModel([
|
|
142
|
-
field('age', {
|
|
143
|
-
type: 'int',
|
|
144
|
-
validation: { min: 18, message: 'Must be at least 18 years old' },
|
|
145
|
-
}),
|
|
146
|
-
]);
|
|
147
|
-
const result = validateFields(model, { age: 10 }, 'create');
|
|
148
|
-
expect(result[0].message).toBe('Must be at least 18 years old');
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('update operation', () => {
|
|
153
|
-
const model = makeModel([
|
|
154
|
-
field('name', { type: 'string', validation: { minLength: 3 } }),
|
|
155
|
-
field('quantity', { type: 'int', validation: { min: 0 } }),
|
|
156
|
-
]);
|
|
157
|
-
|
|
158
|
-
it('only validates fields present in body', () => {
|
|
159
|
-
expect(validateFields(model, { quantity: 5 }, 'update')).toEqual([]);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('validates fields that are present', () => {
|
|
163
|
-
const result = validateFields(model, { name: 'ab' }, 'update');
|
|
164
|
-
expect(result).toHaveLength(1);
|
|
165
|
-
expect(result[0].field).toBe('name');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('skips null/undefined fields', () => {
|
|
169
|
-
expect(validateFields(model, { name: undefined }, 'update')).toEqual([]);
|
|
170
|
-
expect(validateFields(model, { name: null }, 'update')).toEqual([]);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
describe('fields without validation config', () => {
|
|
175
|
-
const model = makeModel([field('title', { type: 'string' }), field('count', { type: 'int' })]);
|
|
176
|
-
|
|
177
|
-
it('skips fields without validation', () => {
|
|
178
|
-
expect(validateFields(model, { title: '', count: -999 }, 'create')).toEqual([]);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('multiple violations', () => {
|
|
183
|
-
const model = makeModel([
|
|
184
|
-
field('name', { type: 'string', validation: { minLength: 3 } }),
|
|
185
|
-
field('quantity', { type: 'int', validation: { min: 0 } }),
|
|
186
|
-
]);
|
|
187
|
-
|
|
188
|
-
it('returns all violations at once', () => {
|
|
189
|
-
const result = validateFields(model, { name: 'ab', quantity: -1 }, 'create');
|
|
190
|
-
expect(result).toHaveLength(2);
|
|
191
|
-
expect(result.map((v) => v.field)).toContain('name');
|
|
192
|
-
expect(result.map((v) => v.field)).toContain('quantity');
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import type { ResolvedModel } from '../schema/types.js';
|
|
2
|
-
import type { ValidationConfig } from '@rangka/shared';
|
|
3
|
-
import { isNil } from '../helpers/coerce.js';
|
|
4
|
-
|
|
5
|
-
export interface FieldViolation {
|
|
6
|
-
field: string;
|
|
7
|
-
rule: string;
|
|
8
|
-
message: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
12
|
-
const URL_RE = /^https?:\/\/.+/;
|
|
13
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
-
|
|
15
|
-
const FORMAT_VALIDATORS: Record<string, (value: string) => boolean> = {
|
|
16
|
-
email: (v) => EMAIL_RE.test(v),
|
|
17
|
-
url: (v) => URL_RE.test(v),
|
|
18
|
-
uuid: (v) => UUID_RE.test(v),
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function validateFields(
|
|
22
|
-
model: ResolvedModel,
|
|
23
|
-
body: Record<string, unknown>,
|
|
24
|
-
operation: 'create' | 'update',
|
|
25
|
-
): FieldViolation[] {
|
|
26
|
-
const violations: FieldViolation[] = [];
|
|
27
|
-
|
|
28
|
-
for (const field of model.fields) {
|
|
29
|
-
if (!('validation' in field.config)) continue;
|
|
30
|
-
const validation = (field.config as { validation?: ValidationConfig }).validation;
|
|
31
|
-
if (!validation) continue;
|
|
32
|
-
|
|
33
|
-
const value = body[field.name];
|
|
34
|
-
|
|
35
|
-
if (isNil(value)) {
|
|
36
|
-
if (operation === 'update') continue;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const fieldViolations = validateValue(field.name, value, validation);
|
|
41
|
-
violations.push(...fieldViolations);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return violations;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function validateValue(
|
|
48
|
-
fieldName: string,
|
|
49
|
-
value: unknown,
|
|
50
|
-
config: ValidationConfig,
|
|
51
|
-
): FieldViolation[] {
|
|
52
|
-
const violations: FieldViolation[] = [];
|
|
53
|
-
|
|
54
|
-
if (typeof value === 'string') {
|
|
55
|
-
if (config.minLength !== undefined && value.length < config.minLength) {
|
|
56
|
-
violations.push({
|
|
57
|
-
field: fieldName,
|
|
58
|
-
rule: 'minLength',
|
|
59
|
-
message: config.message ?? `${fieldName} must be at least ${config.minLength} characters`,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (config.maxLength !== undefined && value.length > config.maxLength) {
|
|
64
|
-
violations.push({
|
|
65
|
-
field: fieldName,
|
|
66
|
-
rule: 'maxLength',
|
|
67
|
-
message: config.message ?? `${fieldName} must be at most ${config.maxLength} characters`,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (config.pattern !== undefined) {
|
|
72
|
-
const re = new RegExp(config.pattern);
|
|
73
|
-
if (!re.test(value)) {
|
|
74
|
-
violations.push({
|
|
75
|
-
field: fieldName,
|
|
76
|
-
rule: 'pattern',
|
|
77
|
-
message: config.message ?? `${fieldName} does not match the required pattern`,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (config.format !== undefined) {
|
|
83
|
-
const validator = FORMAT_VALIDATORS[config.format];
|
|
84
|
-
if (validator && !validator(value)) {
|
|
85
|
-
violations.push({
|
|
86
|
-
field: fieldName,
|
|
87
|
-
rule: 'format',
|
|
88
|
-
message: config.message ?? `${fieldName} must be a valid ${config.format}`,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (typeof value === 'number') {
|
|
95
|
-
if (config.min !== undefined && value < config.min) {
|
|
96
|
-
violations.push({
|
|
97
|
-
field: fieldName,
|
|
98
|
-
rule: 'min',
|
|
99
|
-
message: config.message ?? `${fieldName} must be at least ${config.min}`,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (config.max !== undefined && value > config.max) {
|
|
104
|
-
violations.push({
|
|
105
|
-
field: fieldName,
|
|
106
|
-
rule: 'max',
|
|
107
|
-
message: config.message ?? `${fieldName} must be at most ${config.max}`,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return violations;
|
|
113
|
-
}
|
package/src/validation/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { validateFields, type FieldViolation } from './field-validator.js';
|
package/src/widgets/index.ts
DELETED