Skip to content

Commit f1ba76d

Browse files
committed
add domain primitives
1 parent c0efc0f commit f1ba76d

8 files changed

Lines changed: 964 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import {AppModule, contentHashIdentity} from './app-module.js'
2+
import {ModuleSpecification} from '../module-specification/module-specification.js'
3+
import {Contract} from '../contract/contract.js'
4+
import {describe, expect, test} from 'vitest'
5+
6+
function specWithContract(
7+
contract?: Contract,
8+
overrides: Partial<ConstructorParameters<typeof ModuleSpecification>[0]> = {},
9+
): ModuleSpecification {
10+
return new ModuleSpecification({
11+
identifier: 'test_spec',
12+
name: 'Test',
13+
externalIdentifier: 'test_spec_external',
14+
contract,
15+
appModuleLimit: 1,
16+
uidIsClientProvided: false,
17+
features: [],
18+
...overrides,
19+
})
20+
}
21+
22+
describe('AppModule', () => {
23+
describe('construction', () => {
24+
test('deep copies config', () => {
25+
const config = {name: 'hello', nested: {value: 42}}
26+
const mod = new AppModule({
27+
spec: specWithContract(),
28+
config,
29+
sourcePath: '/tmp/shopify.app.toml',
30+
})
31+
32+
config.name = 'mutated'
33+
;(config.nested as {value: number}).value = 0
34+
35+
expect(mod.config.name).toBe('hello')
36+
expect((mod.config.nested as {value: number}).value).toBe(42)
37+
})
38+
39+
test('preserves sourcePath', () => {
40+
const mod = new AppModule({
41+
spec: specWithContract(),
42+
config: {name: 'test'},
43+
sourcePath: '/tmp/extensions/my-ext/shopify.extension.toml',
44+
})
45+
expect(mod.sourcePath).toBe('/tmp/extensions/my-ext/shopify.extension.toml')
46+
})
47+
48+
test('preserves directory and entryPath when provided', () => {
49+
const mod = new AppModule({
50+
spec: specWithContract(),
51+
config: {},
52+
sourcePath: '/tmp/ext.toml',
53+
directory: '/tmp/extensions/my-ext',
54+
entryPath: '/tmp/extensions/my-ext/src/index.ts',
55+
})
56+
expect(mod.directory).toBe('/tmp/extensions/my-ext')
57+
expect(mod.entryPath).toBe('/tmp/extensions/my-ext/src/index.ts')
58+
})
59+
})
60+
61+
describe('validation state', () => {
62+
test('starts as unvalidated', () => {
63+
const mod = new AppModule({
64+
spec: specWithContract(),
65+
config: {},
66+
sourcePath: '/tmp/app.toml',
67+
})
68+
expect(mod.isUnvalidated).toBe(true)
69+
expect(mod.isValid).toBe(false)
70+
expect(mod.isInvalid).toBe(false)
71+
expect(mod.errors).toHaveLength(0)
72+
})
73+
74+
test('transitions to valid when contract passes', async () => {
75+
const contract = await Contract.fromJsonSchema(
76+
JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}),
77+
)
78+
const mod = new AppModule({
79+
spec: specWithContract(contract),
80+
config: {name: 'hello'},
81+
sourcePath: '/tmp/app.toml',
82+
})
83+
84+
const state = mod.validate()
85+
expect(state.status).toBe('valid')
86+
expect(mod.isValid).toBe(true)
87+
expect(mod.isInvalid).toBe(false)
88+
})
89+
90+
test('transitions to invalid when contract fails', async () => {
91+
const contract = await Contract.fromJsonSchema(
92+
JSON.stringify({
93+
type: 'object',
94+
properties: {name: {type: 'string'}},
95+
required: ['name'],
96+
additionalProperties: false,
97+
}),
98+
)
99+
const mod = new AppModule({
100+
spec: specWithContract(contract),
101+
config: {wrong_field: 'oops'},
102+
sourcePath: '/tmp/app.toml',
103+
})
104+
105+
const state = mod.validate()
106+
expect(state.status).toBe('invalid')
107+
expect(mod.isInvalid).toBe(true)
108+
expect(mod.errors.length).toBeGreaterThan(0)
109+
})
110+
111+
test('is valid when spec has no contract', () => {
112+
const mod = new AppModule({
113+
spec: specWithContract(undefined),
114+
config: {anything: 'goes'},
115+
sourcePath: '/tmp/app.toml',
116+
})
117+
118+
mod.validate()
119+
expect(mod.isValid).toBe(true)
120+
})
121+
122+
test('second validate call returns same state (idempotent)', async () => {
123+
const contract = await Contract.fromJsonSchema(
124+
JSON.stringify({type: 'object', properties: {name: {type: 'string'}}}),
125+
)
126+
const mod = new AppModule({
127+
spec: specWithContract(contract),
128+
config: {name: 'hello'},
129+
sourcePath: '/tmp/app.toml',
130+
})
131+
132+
const state1 = mod.validate()
133+
const state2 = mod.validate()
134+
expect(state1).toBe(state2)
135+
})
136+
})
137+
138+
describe('identity', () => {
139+
test('uses fixed identity when uidIsClientProvided is false', () => {
140+
const mod = new AppModule({
141+
spec: specWithContract(undefined, {identifier: 'app_home', uidIsClientProvided: false}),
142+
config: {name: 'My App'},
143+
sourcePath: '/tmp/app.toml',
144+
})
145+
expect(mod.handle).toBe('app_home')
146+
expect(mod.uid).toBe('app_home')
147+
})
148+
149+
test('uses config-derived identity when uidIsClientProvided is true', () => {
150+
const mod = new AppModule({
151+
spec: specWithContract(undefined, {identifier: 'function', uidIsClientProvided: true}),
152+
config: {handle: 'my-func', uid: 'custom-uid', name: 'My Function'},
153+
sourcePath: '/tmp/ext.toml',
154+
})
155+
expect(mod.handle).toBe('my-func')
156+
expect(mod.uid).toBe('custom-uid')
157+
})
158+
159+
test('uses explicit identity override when provided', () => {
160+
const mod = new AppModule({
161+
spec: specWithContract(undefined, {identifier: 'webhook_subscription'}),
162+
config: {topic: 'products/create', uri: '/webhooks', filter: ''},
163+
sourcePath: '/tmp/app.toml',
164+
identity: contentHashIdentity(['topic', 'uri', 'filter']),
165+
})
166+
// Handle is a hash of the content fields
167+
expect(mod.handle).toBeTruthy()
168+
expect(mod.handle).not.toBe('webhook_subscription')
169+
// UID is the joined content fields
170+
expect(mod.uid).toBe('products/create::/webhooks::')
171+
})
172+
173+
test('type is always the spec identifier', () => {
174+
const mod = new AppModule({
175+
spec: specWithContract(undefined, {identifier: 'branding'}),
176+
config: {},
177+
sourcePath: '/tmp/app.toml',
178+
})
179+
expect(mod.type).toBe('branding')
180+
})
181+
})
182+
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {ModuleSpecification} from '../module-specification/module-specification.js'
2+
import {ValidationError} from '../contract/contract.js'
3+
import {JsonMapType} from '../../public/node/toml/codec.js'
4+
import {slugify} from '../../public/common/string.js'
5+
import {hashString, nonRandomUUID} from '../../public/node/crypto.js'
6+
7+
export type ValidationState =
8+
| {status: 'unvalidated'}
9+
| {status: 'valid'}
10+
| {status: 'invalid'; errors: ValidationError[]}
11+
12+
/**
13+
* How a module resolves its handle and uid.
14+
*/
15+
export interface ModuleIdentity {
16+
resolveHandle(config: JsonMapType): string
17+
resolveUid(config: JsonMapType, handle: string): string
18+
}
19+
20+
export const fixedIdentity = (id: string): ModuleIdentity => ({
21+
resolveHandle: () => id,
22+
resolveUid: () => id,
23+
})
24+
25+
export const configDerivedIdentity: ModuleIdentity = {
26+
resolveHandle: (config) => (config.handle as string) ?? slugify(config.name as string),
27+
resolveUid: (config, handle) => (config.uid as string) ?? nonRandomUUID(handle),
28+
}
29+
30+
export const contentHashIdentity = (fields: string[]): ModuleIdentity => ({
31+
resolveHandle: (config) => hashString(fields.map((field) => String(config[field] ?? '')).join(':')),
32+
resolveUid: (config) => fields.map((field) => String(config[field] ?? '')).join('::'),
33+
})
34+
35+
/**
36+
* A concrete module instance — a specification paired with actual config data.
37+
*
38+
* Immutable config, one-way validation state. If the underlying file changes,
39+
* the system creates new AppModules — it doesn't update existing ones.
40+
*/
41+
export class AppModule {
42+
readonly spec: ModuleSpecification
43+
readonly config: JsonMapType
44+
readonly sourcePath: string
45+
readonly directory?: string
46+
readonly entryPath?: string
47+
readonly identity: ModuleIdentity
48+
private _state: ValidationState = {status: 'unvalidated'}
49+
50+
constructor(options: {
51+
spec: ModuleSpecification
52+
config: JsonMapType
53+
sourcePath: string
54+
directory?: string
55+
entryPath?: string
56+
identity?: ModuleIdentity
57+
}) {
58+
this.spec = options.spec
59+
this.config = structuredClone(options.config)
60+
this.sourcePath = options.sourcePath
61+
this.directory = options.directory
62+
this.entryPath = options.entryPath
63+
this.identity =
64+
options.identity ??
65+
(options.spec.uidIsClientProvided ? configDerivedIdentity : fixedIdentity(options.spec.identifier))
66+
}
67+
68+
get state(): ValidationState {
69+
return this._state
70+
}
71+
72+
get isValid(): boolean {
73+
return this._state.status === 'valid'
74+
}
75+
76+
get isInvalid(): boolean {
77+
return this._state.status === 'invalid'
78+
}
79+
80+
get isUnvalidated(): boolean {
81+
return this._state.status === 'unvalidated'
82+
}
83+
84+
get errors(): ValidationError[] {
85+
return this._state.status === 'invalid' ? this._state.errors : []
86+
}
87+
88+
/**
89+
* Validates the config and transitions state. Can only be called once.
90+
* How validation works (contract, schema, etc.) is an implementation detail.
91+
*/
92+
validate(): ValidationState {
93+
if (this._state.status !== 'unvalidated') return this._state
94+
95+
const errors = this.spec.contract?.validate(this.config) ?? []
96+
this._state = errors.length === 0 ? {status: 'valid'} : {status: 'invalid', errors}
97+
98+
return this._state
99+
}
100+
101+
get handle(): string {
102+
return this.identity.resolveHandle(this.config)
103+
}
104+
105+
get uid(): string {
106+
return this.identity.resolveUid(this.config, this.handle)
107+
}
108+
109+
get type(): string {
110+
return this.spec.identifier
111+
}
112+
}

0 commit comments

Comments
 (0)