@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.
@@ -0,0 +1,116 @@
1
+
2
+ import Path from 'path'
3
+
4
+ import type { Build, Producer, BuildContext, ProducerResult } from '../types'
5
+
6
+ const ORDERING_SPLIT_RE = /\s*,+\s*/
7
+
8
+ // Runs any producers local to the repo.
9
+ const local_producer: Producer = async (build: Build, ctx: BuildContext) => {
10
+
11
+ ctx.state.local = (ctx.state.local || {})
12
+ let actionDefs = ctx.state.local.actionDefs
13
+
14
+ if (null == actionDefs) {
15
+ actionDefs = ctx.state.local.actionDefs = []
16
+
17
+ // TODO: need to provide project root via build
18
+ let root = Path.resolve(build.path, '..', '..')
19
+
20
+ // TODO: build should do this
21
+ // Config is optional: with no .model-config build linked in, there are no
22
+ // declared actions and the model runs on its own.
23
+ let configBuildResult = build.use.config?.watch?.last
24
+ let configBuild = configBuildResult?.build()
25
+ let config = configBuild?.model || {}
26
+
27
+ let actions = config.sys?.model?.action ||
28
+ // NOTE: backwards compat
29
+ config.sys?.model?.builders ||
30
+ {}
31
+
32
+ let ordering = config.sys?.model?.order?.action
33
+ ordering = null == ordering ? Object.keys(actions) :
34
+ ordering.split(ORDERING_SPLIT_RE).filter((n: string) => null != n && '' != n)
35
+
36
+ // load actions
37
+ for (let name of ordering) {
38
+ let actiondef = actions[name]
39
+
40
+ if (null == actiondef) {
41
+ throw new Error(
42
+ 'Unknown model action "' + name +
43
+ '" referenced in sys.model.order.action')
44
+ }
45
+
46
+ if (null == actiondef.load) {
47
+ throw new Error(
48
+ 'Model action "' + name + '" is missing a "load" path')
49
+ }
50
+
51
+ let actionpath = Path.join(root, actiondef.load)
52
+
53
+ let action = require(actionpath)
54
+
55
+ if (action instanceof Promise) {
56
+ action = await action
57
+ }
58
+
59
+ const step = action.step || 'post'
60
+
61
+ actionDefs.push({ name, actiondef, action, step })
62
+ }
63
+ }
64
+
65
+ const runActionDefs = actionDefs.filter((ad: any) => ctx.step === ad.step || 'all' === ad.step)
66
+
67
+ build.log.info({
68
+ point: ctx.step + '-actions', step: ctx.step, actions: runActionDefs,
69
+ note: runActionDefs.map((ad: any) => ad.name).join(';')
70
+ })
71
+
72
+ let ok = true
73
+ let areslog = []
74
+ let reload = false
75
+
76
+ for (let actionDef of runActionDefs) {
77
+ try {
78
+ // TODO: this call signature needs to be well-defined as it is an external interface
79
+ let ares = await actionDef.action(build.model, build, ctx)
80
+ ok = ok && (null == ares || !!ares.ok)
81
+ reload = reload || ares?.reload
82
+
83
+ areslog.push(ares)
84
+
85
+ if (!ok) { break }
86
+ }
87
+ catch (err: any) {
88
+ if (!err.__logged__) {
89
+ build.log.error({
90
+ point: ctx.step + '-action', step: ctx.step, action: actionDef,
91
+ note: actionDef.name,
92
+ err
93
+ })
94
+ err.__logged__ = true
95
+ }
96
+ throw err
97
+ }
98
+ }
99
+
100
+ let pr: ProducerResult = {
101
+ ok,
102
+ reload,
103
+ name: 'local',
104
+ step: ctx.step,
105
+ active: true,
106
+ errs: [],
107
+ runlog: []
108
+ }
109
+
110
+ return pr
111
+ }
112
+
113
+
114
+ export {
115
+ local_producer
116
+ }
@@ -0,0 +1,122 @@
1
+
2
+ import Path from 'path'
3
+
4
+ import type { Build, Producer, BuildContext, ProducerResult } from '../types'
5
+
6
+
7
+ // Serialize the model to two-space-indented JSON with object keys in strictly
8
+ // lexical (UTF-8 byte) order, byte-for-byte identical to the Go
9
+ // implementation's encoding/json. JSON.stringify cannot express this: JS
10
+ // objects iterate integer-like keys ("9", "10") in numeric order ahead of the
11
+ // other keys regardless of insertion order, so the order must be imposed
12
+ // during serialization, and the default JS string sort compares UTF-16 code
13
+ // units, which disagrees with Go's byte order for astral-plane keys. Arrays
14
+ // keep their order. Values only producer mutation can introduce mirror
15
+ // JSON.stringify: undefined, functions, and symbols are dropped from objects
16
+ // and become null in arrays (sparse holes too), and toJSON results are fed
17
+ // back through the canonical serializer.
18
+ function jsonify(value: any, indent: string): string {
19
+ if (Array.isArray(value)) {
20
+ if (0 === value.length) {
21
+ return '[]'
22
+ }
23
+ const inner = indent + ' '
24
+ // Array.from visits holes (as undefined); map/join would skip them.
25
+ return '[\n' +
26
+ Array.from(value, (item) => inner + jsonify(item, inner)).join(',\n') +
27
+ '\n' + indent + ']'
28
+ }
29
+
30
+ if (null != value && 'object' === typeof value) {
31
+ if ('function' === typeof value.toJSON) {
32
+ return jsonify(value.toJSON(), indent)
33
+ }
34
+ const keys = Object.keys(value)
35
+ .filter((key) => {
36
+ const item = value[key]
37
+ return undefined !== item &&
38
+ 'function' !== typeof item && 'symbol' !== typeof item
39
+ })
40
+ .sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
41
+ if (0 === keys.length) {
42
+ return '{}'
43
+ }
44
+ const inner = indent + ' '
45
+ return '{\n' +
46
+ keys.map((key) =>
47
+ inner + jstr(key) + ': ' + jsonify(value[key], inner))
48
+ .join(',\n') +
49
+ '\n' + indent + '}'
50
+ }
51
+
52
+ const scalar = jstr(value)
53
+ return undefined === scalar ? 'null' : scalar
54
+ }
55
+
56
+
57
+ // JSON.stringify, plus the U+2028/U+2029 escapes Go's encoding/json always
58
+ // applies even with HTML escaping off. JSON.stringify emits the separators
59
+ // literally (both forms are valid JSON), so escape them here for byte parity.
60
+ function jstr(value: any): string | undefined {
61
+ const out = JSON.stringify(value)
62
+ return undefined === out ? undefined :
63
+ out.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
64
+ }
65
+
66
+
67
+ // Builds the main model file, after unification.
68
+ const model_producer: Producer = async (build: Build, ctx: BuildContext) => {
69
+ let pr: ProducerResult = {
70
+ ok: true,
71
+ name: 'model',
72
+ reload: false,
73
+ step: ctx.step,
74
+ active: true,
75
+ errs: [],
76
+ runlog: []
77
+ }
78
+
79
+ if ('post' !== ctx.step) {
80
+ return pr
81
+ }
82
+
83
+ let json = jsonify(build.model, '')
84
+
85
+ let filename = Path.basename(build.path)
86
+ let filenameparts = filename.match(/^(.*)\.[^.]+$/)
87
+ if (filenameparts) {
88
+ filename = filenameparts[1]
89
+ }
90
+
91
+ let file = build.opts.base + '/' + filename + '.json'
92
+
93
+ // Skip write when output is unchanged — avoids mtime churn that would
94
+ // invalidate caches (here and in downstream watchers).
95
+ let existing: string | undefined
96
+ try { existing = build.fs.readFileSync(file, 'utf8') } catch { }
97
+
98
+ if (existing === json) {
99
+ build.log.debug({
100
+ point: 'write-model-skip',
101
+ path: file,
102
+ note: file.replace(process.cwd(), '.') + ' (unchanged)'
103
+ })
104
+ return pr
105
+ }
106
+
107
+ build.log.info({
108
+ point: 'write-model',
109
+ path: file,
110
+ note: file.replace(process.cwd(), '.')
111
+ })
112
+
113
+ build.fs.mkdirSync(Path.dirname(file), { recursive: true })
114
+ build.fs.writeFileSync(file, json)
115
+
116
+
117
+ return pr
118
+ }
119
+
120
+ export {
121
+ model_producer
122
+ }
@@ -0,0 +1,207 @@
1
+ /* Copyright © 2026 Voxgig Ltd, MIT License. */
2
+
3
+ import type { Build, Producer, BuildContext, ProducerResult } from '../types'
4
+
5
+
6
+ // Message declarations live in `main.msg` and come in two shapes.
7
+ //
8
+ // The legacy shape nests the pattern pairs, so the pattern is the path down
9
+ // to the definition, and the definition sits at whatever depth that reaches:
10
+ //
11
+ // aim: web: { on: todo: { save: item: { '$': { file: './web_save_item' } } } }
12
+ //
13
+ // The declared shape is flat - one entry per message, keyed by the message
14
+ // name, with the pattern as data and the definition at a known depth:
15
+ //
16
+ // save_item: { pat: [ {aim: web}, {save: item} ], doc: "Save a todo item" }
17
+ //
18
+ // The two are told apart by `pat`: a definition is a map holding a `pat`
19
+ // LIST, and a legacy chain node never holds one, because every value in a
20
+ // chain node is a map - either the next pattern level or the '$' leaf. So the
21
+ // discriminator holds even for a legacy pattern pair that happens to be
22
+ // spelled `pat:`. Anything without a `pat` list is left alone entirely, which
23
+ // is what lets the two shapes coexist while models migrate message by
24
+ // message.
25
+ //
26
+ // Only the declared shape is checked here. The checks are the two things the
27
+ // flat shape makes checkable and the nested one did not:
28
+ //
29
+ // 1. the entry key agrees with the last pattern pair - the key names the
30
+ // action file, a convention the legacy shape got implicitly from the
31
+ // chain's leaf, and which becomes a real consistency check once the key
32
+ // is written out by hand;
33
+ // 2. no two messages declare the same pattern - previously impossible to
34
+ // state twice, because the pattern WAS the path.
35
+
36
+
37
+ // Report a problem against the message it belongs to.
38
+ function msgerr(name: string, why: string): string {
39
+ return 'model msg "' + name + '": ' + why
40
+ }
41
+
42
+
43
+ function isObj(val: any): boolean {
44
+ return null != val && 'object' === typeof val && !Array.isArray(val)
45
+ }
46
+
47
+
48
+ // A message definition declares its pattern as a list; a chain node never does.
49
+ function isMsgDef(val: any): boolean {
50
+ return isObj(val) && Array.isArray(val.pat)
51
+ }
52
+
53
+
54
+ // Sort in UTF-8 byte order, matching Go's sort.Strings, so both
55
+ // implementations report the same problems in the same order. The default JS
56
+ // string sort compares UTF-16 code units, which disagrees for astral-plane
57
+ // names (see the model producer's jsonify, which sorts keys the same way).
58
+ function sortNames(names: string[]): string[] {
59
+ return names.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))
60
+ }
61
+
62
+
63
+ // Validate the declared-shape message entries in main.msg, returning one
64
+ // message per problem found (empty when the model is valid, which includes a
65
+ // model with no messages at all, or only legacy chains).
66
+ function checkMsg(model: any): string[] {
67
+ const problems: string[] = []
68
+
69
+ const msg = model?.main?.msg
70
+ if (!isObj(msg)) {
71
+ return problems
72
+ }
73
+
74
+ // Canonical pattern -> the message that claimed it first.
75
+ const seen: { [canon: string]: string } = {}
76
+
77
+ for (const name of sortNames(Object.keys(msg))) {
78
+ const def = msg[name]
79
+
80
+ // Legacy chain node (or not a map at all): not this check's business.
81
+ if (!isMsgDef(def)) {
82
+ continue
83
+ }
84
+
85
+ const pat: any[] = def.pat
86
+
87
+ if (0 === pat.length) {
88
+ problems.push(msgerr(name, 'pat declares no pattern pairs'))
89
+ continue
90
+ }
91
+
92
+ // Reduce the pattern to its pairs, stopping at the first malformed one -
93
+ // the rest of the checks read the pairs, so there is nothing further to
94
+ // say about this message until its pattern is well-formed.
95
+ //
96
+ // Two renderings: `pairs` reads well in a message, and `canon` identifies
97
+ // the pattern. They cannot be the same string, because `key:value` joined
98
+ // by commas is ambiguous once a key or value contains a delimiter -
99
+ // [{a: "b,c:d"}] and [{a: b}, {c: d}] would both render `a:b,c:d` and the
100
+ // second would be rejected as a duplicate of the first. Quoting each part
101
+ // removes the ambiguity: a delimiter inside a part is escaped, so only
102
+ // genuinely equal patterns produce equal keys.
103
+ const pairs: string[] = []
104
+ const canon: string[] = []
105
+ let last: string[] | undefined
106
+
107
+ for (let pI = 0; pI < pat.length; pI++) {
108
+ const pair = pat[pI]
109
+ const keys = isObj(pair) ? Object.keys(pair) : []
110
+
111
+ if (1 !== keys.length) {
112
+ problems.push(msgerr(name, 'pat pair ' + pI +
113
+ ' is not a single key:value pair'))
114
+ last = undefined
115
+ break
116
+ }
117
+
118
+ const key = keys[0]
119
+ const val = pair[key]
120
+
121
+ if ('string' !== typeof val) {
122
+ problems.push(msgerr(name, 'pat pair ' + pI + ' (' + key +
123
+ ') value is not a string'))
124
+ last = undefined
125
+ break
126
+ }
127
+
128
+ pairs.push(key + ':' + val)
129
+ canon.push(JSON.stringify(key) + ':' + JSON.stringify(val))
130
+ last = [key, val]
131
+ }
132
+
133
+ if (null == last) {
134
+ continue
135
+ }
136
+
137
+ // The entry key names the action file, so it must agree with the last
138
+ // pattern pair.
139
+ const expected = last[0] + '_' + last[1]
140
+ if (name !== expected) {
141
+ problems.push(msgerr(name, 'key does not match last pat pair ' +
142
+ last[0] + ':' + last[1] + ' (expected "' + expected + '")'))
143
+ }
144
+
145
+ const canonKey = canon.join(',')
146
+ if (null == seen[canonKey]) {
147
+ seen[canonKey] = name
148
+ }
149
+ else {
150
+ problems.push(msgerr(name, 'pat [' + pairs.join(',') +
151
+ '] is already declared by "' + seen[canonKey] + '"'))
152
+ }
153
+ }
154
+
155
+ return problems
156
+ }
157
+
158
+
159
+ // Checks the message declarations before anything is written.
160
+ //
161
+ // This runs in BOTH phases, and must: a `pre` action can rewrite model source
162
+ // and request a reload, and the build re-resolves the model AFTER the pre
163
+ // phase has finished (see BuildImpl.run). A pre-only check would then have
164
+ // validated a model that no longer exists, and the model producer would write
165
+ // the regenerated one unchecked. Checking again in post closes that window -
166
+ // this producer is first in the pipeline, so it still runs ahead of the model
167
+ // producer, and a build whose model went bad during a reload fails with
168
+ // nothing written.
169
+ const msg_producer: Producer = async (build: Build, ctx: BuildContext) => {
170
+ const pr: ProducerResult = {
171
+ ok: true,
172
+ name: 'msg',
173
+ reload: false,
174
+ step: ctx.step,
175
+ active: true,
176
+ errs: [],
177
+ runlog: []
178
+ }
179
+
180
+ const problems = checkMsg(build.model)
181
+
182
+ if (0 < problems.length) {
183
+ pr.ok = false
184
+ pr.errs = problems.map((problem) => new Error(problem))
185
+
186
+ // Add them to the build too. BuildImpl.run collects the errors a producer
187
+ // THROWS, but not the ones it returns, so a returned error would
188
+ // otherwise be missing from the BuildResult. (The Go port needs no such
189
+ // push: its runProducer merges a failed producer's Errs itself. Both
190
+ // implementations end up with the same errors on the build.)
191
+ build.errs.push(...pr.errs)
192
+
193
+ build.log.error({
194
+ point: 'msg-invalid',
195
+ count: problems.length,
196
+ note: problems.join('; ')
197
+ })
198
+ }
199
+
200
+ return pr
201
+ }
202
+
203
+
204
+ export {
205
+ msg_producer,
206
+ checkMsg,
207
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "tsBuildInfoFile": "../dist/tsconfig.tsbuildinfo",
5
+ "esModuleInterop": true,
6
+ "module": "nodenext",
7
+ "noEmitOnError": true,
8
+ "outDir":"../dist",
9
+ "rootDir":".",
10
+ "resolveJsonModule": true,
11
+ "skipLibCheck": true,
12
+ "sourceMap": true,
13
+ "strict": true,
14
+ "target": "es2021",
15
+ "declaration": true,
16
+ "declarationDir": "../dist"
17
+ }
18
+ }
19
+
package/src/types.ts ADDED
@@ -0,0 +1,162 @@
1
+ /* Copyright © 2021-2024 Voxgig Ltd, MIT License. */
2
+
3
+ import Fs from 'node:fs'
4
+
5
+ import Pino from 'pino'
6
+
7
+
8
+ import type { Aontu } from 'aontu'
9
+
10
+
11
+ type FST = typeof Fs
12
+
13
+ type Log = ReturnType<typeof Pino>
14
+
15
+ interface Build {
16
+ id: string
17
+ base: string
18
+ path: string
19
+ opts: { [key: string]: any }
20
+ pdef: ProducerDef[]
21
+ spec: BuildSpec
22
+ model: any
23
+ use: { [name: string]: any }
24
+ errs: any[]
25
+ ctx: BuildContext
26
+ deps: any
27
+ run: (rspec: RunSpec) => Promise<BuildResult>
28
+ log: Log
29
+ fs: FST
30
+ dryrun: boolean
31
+ args: any
32
+ aontu: Aontu
33
+
34
+ }
35
+
36
+
37
+ interface BuildResult {
38
+ ok: boolean
39
+ builder?: string
40
+ path?: string
41
+ producers?: ProducerResult[]
42
+ step?: string
43
+ errs: any[]
44
+ runlog: string[]
45
+ build?: () => Build
46
+ }
47
+
48
+
49
+ interface BuildContext {
50
+ step: 'pre' | 'post'
51
+ watch: boolean,
52
+ state: Record<string, any>
53
+ }
54
+
55
+
56
+ interface BuildSpec {
57
+ path?: string
58
+ base?: string
59
+ res?: ProducerDef[]
60
+ require?: any
61
+ use?: { [name: string]: any }
62
+ log?: Log
63
+ idle?: number
64
+ name?: string
65
+ debug?: boolean | string
66
+ dryrun?: boolean,
67
+ buildargs?: any,
68
+ watch?: {
69
+ mod?: boolean // file modification
70
+ add?: boolean // file addition
71
+ rem?: boolean // file deletion
72
+ }
73
+ fs: FST
74
+ }
75
+
76
+
77
+ interface ProducerDef {
78
+ path: string
79
+ build: Producer
80
+ }
81
+
82
+ type Producer = (
83
+ build: Build,
84
+ ctx: BuildContext,
85
+ ) => Promise<ProducerResult>
86
+
87
+
88
+ interface ProducerResult {
89
+ ok: boolean
90
+ name: string
91
+ active: boolean
92
+ errs: any[]
93
+ runlog: string[]
94
+ step: string
95
+ reload: boolean
96
+ }
97
+
98
+
99
+ type Run = {
100
+ canon: string
101
+ path: string
102
+ start: number
103
+ end: number
104
+ result?: BuildResult
105
+ }
106
+
107
+
108
+ type RunSpec = {
109
+ watch: boolean
110
+ }
111
+
112
+ type Canon = {
113
+ path: string
114
+ isFolder: boolean
115
+ when: number
116
+ }
117
+
118
+
119
+ type ChangeItem = {
120
+ path: string
121
+ when: number
122
+ }
123
+
124
+ interface ModelSpec {
125
+ path?: string
126
+ base?: string
127
+ require?: any
128
+ log?: Log
129
+ idle?: number
130
+ debug?: boolean | string
131
+ dryrun?: boolean
132
+ buildargs?: any
133
+ fs?: any
134
+ // Resolve a .model-config/model-config.aon (auto-created when missing)
135
+ // that declares the build actions and their order. Defaults to true; set
136
+ // false to skip the config entirely and run the model on its own.
137
+ config?: boolean
138
+ watch?: {
139
+ mod?: boolean // file modification
140
+ add?: boolean // file addition
141
+ rem?: boolean // file deletion
142
+ }
143
+ }
144
+
145
+
146
+
147
+ export type {
148
+ Build,
149
+ BuildResult,
150
+ ProducerDef,
151
+ Producer,
152
+ ProducerResult,
153
+ BuildContext,
154
+ BuildSpec,
155
+ Log,
156
+ Run,
157
+ Canon,
158
+ ChangeItem,
159
+ RunSpec,
160
+ ModelSpec,
161
+ FST,
162
+ }