@voxgig/model 10.0.1 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/build.ts ADDED
@@ -0,0 +1,254 @@
1
+ /* Copyright © 2021-2024 Voxgig Ltd, MIT License. */
2
+
3
+
4
+ import { Aontu } from 'aontu'
5
+
6
+
7
+ import type {
8
+ Build,
9
+ BuildResult,
10
+ BuildContext,
11
+ BuildSpec,
12
+ RunSpec,
13
+ Log,
14
+ ProducerDef
15
+ } from './types'
16
+
17
+
18
+ class BuildImpl implements Build {
19
+ id
20
+ base
21
+ path
22
+ opts: any
23
+ pdef: ProducerDef[]
24
+ spec: BuildSpec
25
+ model: any
26
+ use = {}
27
+ errs: any[] = []
28
+ ctx: BuildContext
29
+ log: Log
30
+ fs: any
31
+ dryrun: boolean
32
+ args: any
33
+ aontu: Aontu
34
+ deps: any
35
+
36
+ // Signature of the last successful generate: path -> mtimeMs (-1 = missing).
37
+ // When every tracked file still matches, resolveModel() reuses this.model.
38
+ cacheSig: Map<string, number> | null = null
39
+
40
+
41
+ constructor(spec: BuildSpec, log: Log) {
42
+ this.id = String(Math.random()).substring(3, 9)
43
+ this.log = log
44
+
45
+ this.spec = spec
46
+
47
+ this.dryrun = !!spec.dryrun
48
+ this.args = spec.buildargs
49
+ this.fs = spec.fs
50
+ this.base = null == spec.base ? '' : spec.base
51
+ this.path = null == spec.path ? '' : spec.path
52
+ this.opts = {}
53
+ this.ctx = { step: 'pre', watch: false, state: {} }
54
+
55
+ if (null != spec.base) {
56
+ this.opts.base = spec.base
57
+ this.opts.path = spec.path
58
+ }
59
+
60
+ if (null != spec.require) {
61
+ this.opts.require = spec.require
62
+ }
63
+
64
+ this.pdef = spec.res || []
65
+
66
+ Object.assign(this.use, spec.use || {})
67
+
68
+ this.deps = {}
69
+ this.aontu = new Aontu()
70
+
71
+ // Aontu comments are `#` only. The npm engine's jsonic parser also
72
+ // enables `//` and `/* */` by default, which the Go engine does not
73
+ // support — disable them so both implementations reject the same sources.
74
+ this.aontu.lang.jsonic.options({
75
+ comment: { def: { slash: null, multi: null } }
76
+ })
77
+ }
78
+
79
+
80
+ async run(rspec: RunSpec): Promise<BuildResult> {
81
+ let hasErr = false
82
+ let runlog = []
83
+
84
+ // Reset per-run error state. The BuildImpl is reused across watch
85
+ // rebuilds, so without this a single failure would stick to every
86
+ // later build. Reassign (don't clear in place) so a previously
87
+ // returned BuildResult keeps its own errors.
88
+ this.errs = []
89
+
90
+ this.ctx = { step: 'pre', state: {}, watch: rspec.watch }
91
+ const plog: any[] = []
92
+
93
+ if (!hasErr) {
94
+ runlog.push('model:initial')
95
+ hasErr = await this.resolveModel()
96
+ }
97
+
98
+ let forceReload = false
99
+
100
+ if (!hasErr) {
101
+ for (let producer of this.pdef) {
102
+ try {
103
+ runlog.push('producer:pre:' + producer.build.name)
104
+ let pr = await producer.build(this, this.ctx)
105
+ forceReload = forceReload || pr.reload
106
+ plog.push(pr)
107
+ if (!pr.ok) {
108
+ hasErr = true
109
+ break
110
+ }
111
+ }
112
+ catch (err: any) {
113
+ hasErr = true
114
+ this.errs.push(err)
115
+ break
116
+ }
117
+ }
118
+ }
119
+
120
+ // Only reload when a pre-producer actually modified model sources
121
+ // (signalled via pr.reload). Previously this always ran on success.
122
+ const reload = forceReload && !hasErr
123
+
124
+ if (reload) {
125
+ runlog.push('model:full')
126
+ hasErr = await this.resolveModel()
127
+ }
128
+
129
+ if (!hasErr) {
130
+ this.ctx.step = 'post'
131
+
132
+ for (let producer of this.pdef) {
133
+ try {
134
+ runlog.push('producer:post:' + producer.build.name)
135
+ let pr = await producer.build(this, this.ctx)
136
+ plog.push(pr)
137
+ if (!pr.ok) {
138
+ hasErr = true
139
+ break
140
+ }
141
+ }
142
+ catch (err: any) {
143
+ hasErr = true
144
+ this.errs.push(err)
145
+ break
146
+ }
147
+ }
148
+
149
+ }
150
+
151
+ const br: BuildResult =
152
+ {
153
+ // TODO: remove need for this
154
+ build: () => this,
155
+
156
+ ok: !hasErr,
157
+ producers: plog,
158
+ errs: this.errs,
159
+ runlog
160
+ }
161
+
162
+ return br
163
+ }
164
+
165
+
166
+ async resolveModel() {
167
+ if (this.model && this.cacheSig && this.cacheHit()) {
168
+ return false
169
+ }
170
+
171
+ let hasErr = false
172
+
173
+ let src: string = ''
174
+ if (!hasErr) {
175
+ try {
176
+ src = this.fs.readFileSync(this.path, 'utf8')
177
+ }
178
+ catch (err: any) {
179
+ hasErr = true
180
+ this.errs.push(err)
181
+ }
182
+ }
183
+
184
+ if (!hasErr) {
185
+ // Collect this generation's errors into a fresh array. The option key
186
+ // is `err` (not `errs`) — that is what aontu reads, and providing it
187
+ // puts aontu in collect mode so model errors are gathered rather than
188
+ // thrown. A per-call array avoids leaking errors into later builds.
189
+ const modelErrs: any[] = []
190
+ this.opts.err = modelErrs
191
+ this.opts.deps = this.deps
192
+ this.opts.fs = this.fs
193
+
194
+ try {
195
+ this.model = this.aontu.generate(src, this.opts)
196
+ }
197
+ catch (err: any) {
198
+ // collect mode normally prevents throws, but guard the rare cases
199
+ // (e.g. unresolved imports) so they surface as build errors.
200
+ modelErrs.push(err)
201
+ }
202
+
203
+ if (0 < modelErrs.length) {
204
+ hasErr = true
205
+ this.errs.push(...modelErrs)
206
+ }
207
+ }
208
+
209
+ this.cacheSig = hasErr ? null : this.snapshotSig()
210
+
211
+ return hasErr
212
+ }
213
+
214
+
215
+ // Collect mtimeMs for the root file and every file aontu recorded as a dep.
216
+ snapshotSig(): Map<string, number> {
217
+ const sig = new Map<string, number>()
218
+ sig.set(this.path, mtime(this.fs, this.path))
219
+ for (const parent of Object.keys(this.deps)) {
220
+ for (const child of Object.keys(this.deps[parent])) {
221
+ if (!sig.has(child)) sig.set(child, mtime(this.fs, child))
222
+ }
223
+ }
224
+ return sig
225
+ }
226
+
227
+
228
+ cacheHit(): boolean {
229
+ if (!this.cacheSig) return false
230
+ for (const [path, prev] of this.cacheSig) {
231
+ if (mtime(this.fs, path) !== prev) return false
232
+ }
233
+ return true
234
+ }
235
+ }
236
+
237
+
238
+ function mtime(fs: any, path: string): number {
239
+ try { return fs.statSync(path).mtimeMs }
240
+ catch { return -1 }
241
+ }
242
+
243
+
244
+ function makeBuild(spec: BuildSpec, log: Log) {
245
+ return new BuildImpl(spec, log)
246
+ }
247
+
248
+ export {
249
+ makeBuild,
250
+ BuildSpec,
251
+ }
252
+
253
+
254
+
package/src/config.ts ADDED
@@ -0,0 +1,49 @@
1
+ /* Copyright © 2021-2025 Voxgig Ltd, MIT License. */
2
+
3
+
4
+ import type { BuildResult, BuildSpec, Log } from './types'
5
+
6
+ import { Watch } from './watch'
7
+
8
+
9
+
10
+ class Config {
11
+ build: BuildSpec
12
+ watch: Watch
13
+ log: Log
14
+
15
+ constructor(spec: BuildSpec, log: Log) {
16
+ this.log = log
17
+
18
+ this.build = {
19
+ path: spec.path,
20
+ base: spec.base,
21
+ res: [
22
+ ...(spec.res || [])
23
+ ],
24
+ require: spec.require,
25
+ log: this.log,
26
+ fs: spec.fs
27
+ }
28
+
29
+ this.watch = new Watch(this.build, this.log)
30
+ }
31
+
32
+ async run(watch: boolean): Promise<BuildResult> {
33
+ return this.watch.run('config', watch, '<config>')
34
+ }
35
+
36
+ async start(initial: boolean = true) {
37
+ return this.watch.start(initial)
38
+ }
39
+
40
+ async stop() {
41
+ return this.watch.stop()
42
+ }
43
+ }
44
+
45
+
46
+ export { Config, BuildSpec }
47
+
48
+
49
+
package/src/init.ts ADDED
@@ -0,0 +1,65 @@
1
+ /* Copyright © 2021-2025 Voxgig Ltd, MIT License. */
2
+
3
+ import Path from 'node:path'
4
+
5
+
6
+ const STARTER_MODEL = `# Voxgig model. Edit this file, then build it:
7
+ # voxgig-model model/model.aon
8
+ #
9
+ # Models are unified .aon - add types, defaults, references, imports.
10
+ # Tutorial: https://github.com/voxgig/model/blob/main/docs/tutorial.md
11
+
12
+ name: 'my-model'
13
+ `
14
+
15
+ const STARTER_CONFIG = `# Model configuration. Declare build actions and their order here.
16
+ #
17
+ # Example (TypeScript loads the module; Go binds the name to a
18
+ # registered action func):
19
+ # sys: model: action: { example: load: 'build/example' }
20
+ # sys: model: order: action: 'example'
21
+
22
+ sys: model: action: {}
23
+ sys: model: order: action: *''
24
+ `
25
+
26
+
27
+ type InitResult = {
28
+ created: string[]
29
+ skipped: string[]
30
+ }
31
+
32
+
33
+ // Scaffold a starter model and config under <dir>/model. Existing files are
34
+ // left untouched.
35
+ function initModel(dir: string, fs: any): InitResult {
36
+ const d = dir || '.'
37
+ const files: [string, string][] = [
38
+ [Path.join(d, 'model', 'model.aon'), STARTER_MODEL],
39
+ [Path.join(d, 'model', '.model-config', 'model-config.aon'), STARTER_CONFIG],
40
+ ]
41
+
42
+ const created: string[] = []
43
+ const skipped: string[] = []
44
+
45
+ for (const [p, content] of files) {
46
+ if (fs.existsSync(p)) {
47
+ skipped.push(p)
48
+ continue
49
+ }
50
+ fs.mkdirSync(Path.dirname(p), { recursive: true })
51
+ fs.writeFileSync(p, content)
52
+ created.push(p)
53
+ }
54
+
55
+ return { created, skipped }
56
+ }
57
+
58
+
59
+ export {
60
+ initModel,
61
+ }
62
+
63
+ export type {
64
+ InitResult,
65
+ }
package/src/model.ts ADDED
@@ -0,0 +1,330 @@
1
+ /* Copyright © 2021-2025 Voxgig Ltd, MIT License. */
2
+
3
+
4
+ import * as NodeFs from 'node:fs'
5
+
6
+ import { memfs as MemFs } from 'memfs'
7
+
8
+ import { prettyPino } from '@voxgig/util'
9
+
10
+ import type {
11
+ Build,
12
+ BuildResult,
13
+ ProducerDef,
14
+ BuildContext,
15
+ BuildSpec,
16
+ ModelSpec,
17
+ Log,
18
+ FST,
19
+ ProducerResult,
20
+ } from './types'
21
+
22
+
23
+ import { Config } from './config'
24
+ import { Watch } from './watch'
25
+
26
+ import { model_producer } from './producer/model'
27
+ import { local_producer } from './producer/local'
28
+ import { msg_producer } from './producer/msg'
29
+
30
+ import { initModel } from './init'
31
+
32
+
33
+ class Model {
34
+ config?: Config
35
+ build: BuildSpec
36
+ watch: Watch
37
+
38
+ trigger_model = false
39
+
40
+ log: Log
41
+ fs: any
42
+
43
+ constructor(mspec: ModelSpec) {
44
+ const self = this
45
+
46
+ this.fs = { ...(mspec.fs || NodeFs) }
47
+
48
+ if (mspec.dryrun) {
49
+ makeReadOnly(this.fs)
50
+ }
51
+
52
+ const pino = prettyPino('model', mspec as any)
53
+
54
+ this.log = pino.child({ cmp: 'model' })
55
+
56
+ this.log.info({ point: 'model-init' })
57
+ if (this.log.isLevelEnabled('debug')) {
58
+ this.log.debug({
59
+ point: 'model-spec', mspec, note: '\n' +
60
+ JSON.stringify({ ...mspec, src: '<NOT-SHOWN>' }, null, 2)
61
+ .replace(/"/g, '')
62
+ .replaceAll(process.cwd(), '.')
63
+ })
64
+ }
65
+
66
+ // Config is a special Watch to handle model config. It is optional: when
67
+ // mspec.config is false, the .model-config/ build is skipped entirely and
68
+ // the model runs on its own (see run/start below).
69
+ const useConfig = false !== mspec.config
70
+
71
+ this.config = !useConfig ? undefined : makeConfig(mspec, this.log, this.fs, {
72
+ path: '/',
73
+ build: async function trigger_model(build: Build, ctx: BuildContext) {
74
+ let pres: ProducerResult = {
75
+ ok: false, name: 'config', step: '', active: true, reload: false, errs: [], runlog: []
76
+ }
77
+
78
+ if ('post' !== ctx.step) {
79
+ pres.ok = true
80
+ return pres
81
+ }
82
+
83
+
84
+ if (self.trigger_model) {
85
+
86
+ // TODO: better design
87
+ // Point the config's last result at the current build so the model
88
+ // producer reads fresh config state. It must be a thunk to satisfy
89
+ // BuildResult.build's `() => Build` contract (consumers call it).
90
+ const lastConfig = self.build.use?.config?.watch?.last
91
+ if (lastConfig) {
92
+ lastConfig.build = () => build
93
+ }
94
+
95
+ const br = await self.watch.run('model', true)
96
+ pres.ok = br.ok
97
+ pres.errs = br.errs
98
+ }
99
+ else {
100
+ self.trigger_model = true
101
+ pres.ok = true
102
+ }
103
+
104
+ if (ctx.watch) {
105
+ const watchmap = build.model?.sys?.model?.watch
106
+
107
+ if (watchmap) {
108
+ Object.keys(watchmap).forEach((file: string) => {
109
+ self.watch.add(file)
110
+ })
111
+ }
112
+ }
113
+
114
+ return pres
115
+ }
116
+ })
117
+
118
+ // The actual model.
119
+ this.build = {
120
+ path: mspec.path,
121
+ base: mspec.base,
122
+ debug: mspec.debug,
123
+ dryrun: mspec.dryrun,
124
+ buildargs: mspec.buildargs,
125
+ use: self.config ? { config: self.config } : {},
126
+ res: [
127
+ // Validates message declarations (pre phase), so an inconsistent
128
+ // main.msg fails the build before any output is written.
129
+ {
130
+ path: '/',
131
+ build: msg_producer
132
+ },
133
+ {
134
+ path: '/',
135
+ build: model_producer
136
+ },
137
+ {
138
+ path: '/',
139
+ build: local_producer
140
+ }
141
+ ],
142
+ require: mspec.require,
143
+ log: this.log,
144
+ fs: this.fs,
145
+ watch: mspec.watch,
146
+ }
147
+
148
+ this.watch = new Watch(self.build, this.log)
149
+ }
150
+
151
+
152
+ // Run once. With config enabled, the config build runs first and triggers
153
+ // the model build; without it, the model build runs directly.
154
+ async run(): Promise<BuildResult> {
155
+ this.trigger_model = false
156
+ if (!this.config) {
157
+ return this.watch.run('model', false, '<start>')
158
+ }
159
+ const br = await this.config.run(false)
160
+ return br.ok ? this.watch.run('model', false, '<start>') : br
161
+ }
162
+
163
+
164
+ // Start watching for file changes. Runs an initial build, then watches
165
+ // both the model files and (when enabled) the config files for ongoing
166
+ // changes.
167
+ async start() {
168
+ this.trigger_model = false
169
+ if (!this.config) {
170
+ return this.watch.start()
171
+ }
172
+ const br = await this.config.run(true)
173
+ if (!br.ok) {
174
+ return br
175
+ }
176
+ // Watch config files too. The initial config build is already done
177
+ // above, so start without forcing another one; a later config change
178
+ // rebuilds the config and re-triggers the model build.
179
+ this.config.start(false)
180
+ return this.watch.start()
181
+ }
182
+
183
+
184
+ async stop() {
185
+ // start() also spins up a config-file watcher; stop both so no
186
+ // chokidar handle is left open keeping the process alive.
187
+ await this.config?.stop()
188
+ return this.watch.stop()
189
+ }
190
+ }
191
+
192
+
193
+ function makeConfig(mspec: ModelSpec, log: Log, fs: any, trigger_model_build: ProducerDef) {
194
+ let cbase = mspec.base + '/.model-config'
195
+ let cpath = cbase + '/model-config.aon'
196
+
197
+ // MIGRATE A LEGACY .aontu CONFIG RATHER THAN WRITING OVER IT. This block
198
+ // CREATES the config when it finds none, so looking only for `.aon` in a
199
+ // project that has a `model-config.aontu` would not read the old file — it
200
+ // would decide there is no config and write a fresh default beside it,
201
+ // silently discarding whatever the project had declared there.
202
+ const legacycpath = cbase + '/model-config.aontu'
203
+ if (!fs.existsSync(cpath) && fs.existsSync(legacycpath)) {
204
+ // NOT A VERBATIM COPY. This package's OWN config moved to .aon in v10, so
205
+ // a legacy config's import of it names a file that no longer ships and the
206
+ // migrated config fails to resolve - `aontu/multisource_not_found:
207
+ // @voxgig/model/model/.model-config/model-config.aontu` - on the first
208
+ // build after upgrading. Renaming the file without retargeting that import
209
+ // just moves the breakage.
210
+ //
211
+ // Only THIS package's import is retargeted, and only where it is an
212
+ // IMPORT. Two things are deliberately left alone:
213
+ //
214
+ // - a project's own `.aontu` imports, which still name real files on
215
+ // its disk that nothing here renamed;
216
+ // - this same pathname held as ordinary string DATA (a note, a
217
+ // compatibility path in action metadata).
218
+ //
219
+ // Hence the match is anchored to aontu's `@"..."` import syntax, closing
220
+ // quote included, rather than to the bare pathname. Both are declarations
221
+ // the migration exists to preserve, and silently editing one during a
222
+ // one-time migration is precisely the failure this whole block guards
223
+ // against.
224
+ const legacy = fs.readFileSync(legacycpath, 'utf8')
225
+ fs.writeFileSync(cpath, legacy.replace(
226
+ /@(\s*)"(@voxgig\/model\/[^"]*model-config)\.aontu"/g, '@$1"$2.aon"'))
227
+ try { fs.unlinkSync(legacycpath) } catch (_err: any) { }
228
+ }
229
+
230
+ if (!fs.existsSync(cpath)) {
231
+ fs.mkdirSync(cbase, { recursive: true })
232
+ fs.writeFileSync(cpath, `
233
+ @"@voxgig/model/model/.model-config/model-config.aon"
234
+
235
+ sys: model: action: {}
236
+ `)
237
+ }
238
+
239
+ let cspec: BuildSpec = {
240
+ name: 'config',
241
+ path: cpath,
242
+ base: cbase,
243
+ debug: mspec.debug,
244
+ res: [
245
+
246
+ // Generate full config model and save as a file.
247
+ {
248
+ path: '/',
249
+ build: model_producer
250
+ },
251
+
252
+ // Trigger main model build.
253
+ trigger_model_build
254
+ ],
255
+ require: mspec.require,
256
+ log,
257
+ fs,
258
+ }
259
+
260
+ return new Config(cspec, log)
261
+ }
262
+
263
+
264
+ function makeReadOnly(fsm: FST) {
265
+
266
+ // NOTE: NOT COMPLETE!
267
+ // Just for internal use,
268
+ const writers = [
269
+ 'writeFile',
270
+ 'writeFileSync',
271
+ 'appendFile',
272
+ 'appendFileSync',
273
+ 'chmod',
274
+ 'chmodSync',
275
+ 'chown',
276
+ 'chownSync',
277
+ 'cp',
278
+ 'cpSync',
279
+ 'createWriteStream',
280
+ 'mkdir',
281
+ 'mkdirSync',
282
+ 'rename',
283
+ 'renameSync',
284
+ 'rm',
285
+ 'rmSync',
286
+ 'rmdir',
287
+ 'rmdirSync',
288
+ 'symlink',
289
+ 'symlinkSync',
290
+ 'truncate',
291
+ 'truncateSync',
292
+ 'unlink',
293
+ 'unlinkSync',
294
+ 'write',
295
+ 'writev',
296
+ ]
297
+
298
+ const { fs } = MemFs({ [process.cwd()]: {} })
299
+
300
+ for (let w of writers) {
301
+ if ((fs as any)[w]) {
302
+ (fsm as any)[w] = (fs as any)[w].bind(fs)
303
+ }
304
+ }
305
+
306
+ // Also redirect the promise-based writers. fsm.promises is shared by
307
+ // reference with the real fs module, so replace it with a copy rather
308
+ // than mutating the caller's fs.
309
+ const memPromises = (fs as any).promises
310
+ if ((fsm as any).promises && memPromises) {
311
+ const promises: any = { ...(fsm as any).promises }
312
+ for (let w of writers) {
313
+ if ('function' === typeof memPromises[w]) {
314
+ promises[w] = memPromises[w].bind(memPromises)
315
+ }
316
+ }
317
+ ;(fsm as any).promises = promises
318
+ }
319
+
320
+ return fsm
321
+ }
322
+
323
+
324
+ export {
325
+ Model,
326
+ BuildSpec,
327
+ initModel,
328
+ }
329
+
330
+