-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.js
324 lines (273 loc) · 8.8 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
const fs = require('fs')
const p = require('path')
const {
SupportedTypes,
getDefaultValue
} = require('./lib/types.js')
const generateCode = require('./lib/codegen')
const JSON_FILE_NAME = 'schema.json'
const CODE_FILE_NAME = 'index.js'
class ResolvedType {
constructor (hyperschema, fqn, description, existing) {
this.hyperschema = hyperschema
this.description = description
this.name = description?.name || fqn
this.namespace = description?.namespace || ''
this.derived = description.derived
this.existing = existing
this.fqn = fqn
this.isPrimitive = false
this.isStruct = false
this.isAlias = false
this.version = -1
}
toJSON () {
return {
name: this.name,
namespace: this.namespace
}
}
}
class Primitive extends ResolvedType {
constructor (name) {
super(null, name, name, null)
this.isPrimitive = true
this.bool = this.name === 'bool'
this.default = getDefaultValue(this.name)
}
static AllPrimitives = new Map([...SupportedTypes].map(name => {
return [name, new this(name)]
}))
}
class Alias extends ResolvedType {
constructor (hyperschema, fqn, description, existing) {
super(hyperschema, fqn, description, existing)
this.isAlias = true
this.type = hyperschema.resolve(description.alias)
if (!this.type) throw new Error(`Cannot resolve alias target ${description.alias} in ${description.name}`)
this.default = this.type.default
if (existing) {
if (existing.type.name !== this.type.name) {
throw new Error(`Remapping an alias: ${fqn}`)
}
this.version = existing.version
} else if (!this.derived) {
this.hyperschema.maybeBumpVersion()
this.version = this.hyperschema.version
}
}
toJSON () {
return {
name: this.name,
namespace: this.namespace,
alias: this.type.fqn,
version: this.version
}
}
}
class StructField {
constructor (hyperschema, struct, position, flag, description) {
this.hyperschema = hyperschema
this.description = description
this.name = this.description.name
this.required = this.description.required
this.external = this.description.external
this.position = position
this.struct = struct
this.flag = flag
this.type = hyperschema.resolve(description.type)
if (!this.type) throw new Error(`Cannot resolve field type ${description.type} in ${this.name}`)
this.framed = this.type.isStruct && !this.type.description.compact
this.array = !!this.description.array
this.version = description.version || hyperschema.version
if (this.struct.existing) {
const tag = `${this.struct.fqn}/${this.description.name}`
const prevField = this.struct.existing.fields[position]
if (prevField) {
if (prevField.type.fqn !== this.type.fqn) {
throw new Error(`Field was modified: ${tag}`)
} else if (prevField.required !== this.required) {
throw new Error(`A required field must always stay required: ${tag}`)
}
this.version = prevField.version
} else if (!this.struct.derived) {
hyperschema.maybeBumpVersion()
this.version = hyperschema.version
}
}
}
toJSON () {
return {
name: this.description.name,
required: this.description.required,
array: this.description.array,
type: this.type.fqn,
version: this.version
}
}
}
class Struct extends ResolvedType {
constructor (hyperschema, fqn, description, existing) {
super(hyperschema, fqn, description, existing)
this.isStruct = true
this.default = null
this.fields = []
this.fieldsByName = new Map()
this.optionals = []
this.flagsPosition = -1
this.compact = !!description.compact
if (Number.isInteger(description.flagsPosition)) {
this.flagsPosition = description.flagsPosition
}
if (!description.name) {
throw new Error(`Struct ${this.fqn}: required 'name' definition is missing`)
}
if (!description.fields) {
throw new Error(`Struct ${this.fqn}: required 'fields' definition is missing`)
}
if (this.existing) {
const oldLength = this.existing.fields.length
const newLength = this.description.fields.length
if (oldLength > newLength) {
throw new Error(`A field was removed: ${this.fqn}`)
} else if (this.compact && (oldLength !== newLength)) {
throw new Error(`A compact struct was expanded: ${this.fqn}`)
}
} else if (!this.derived) {
this.hyperschema.maybeBumpVersion()
this.version = this.hyperschema.version
}
for (let i = 0; i < description.fields.length; i++) {
const fieldDescription = description.fields[i]
// bools can only be set in the flag, so auto downgrade the from required
// TODO: if we add semantic meaning to required, ie "user MUST set this", we should
// add an additional state for this
if (fieldDescription.required && fieldDescription.type === 'bool') {
fieldDescription.required = false
}
const flag = !fieldDescription.required ? 2 ** this.optionals.length : 0
const field = new StructField(hyperschema, this, i, flag, fieldDescription)
this.fields.push(field)
this.fieldsByName.set(field.name, field)
if (!fieldDescription.required) {
this.optionals.push(field)
if (this.flagsPosition === -1) {
this.flagsPosition = i
}
}
}
}
toJSON () {
return {
name: this.name,
namespace: this.namespace,
compact: this.compact,
flagsPosition: this.flagsPosition,
fields: this.fields.map(f => f.toJSON())
}
}
}
class HyperschemaNamespace {
constructor (hyperschema, name) {
this.hyperschema = hyperschema
this.name = name
}
register (description) {
return this.hyperschema.register({
...description,
namespace: this.name
})
}
}
module.exports = class Hyperschema {
constructor (json, { dir = null } = {}) {
this.version = json ? json.version : 0
this.schema = []
this.dir = dir
this.namespaces = new Map()
this.positionsByType = new Map()
this.typesByPosition = new Map()
this.types = new Map()
this.changed = false
this.initializing = true
if (json) {
for (let i = 0; i < json.schema.length; i++) {
const description = json.schema[i]
this.register(description)
}
}
this.initializing = false
}
_getFullyQualifiedName (description) {
if (description.namespace === null) return description.name
return '@' + description.namespace + '/' + description.name
}
maybeBumpVersion () {
if (this.changed || this.initializing) return
this.changed = true
this.version += 1
}
register (description) {
const fqn = this._getFullyQualifiedName(description)
const existing = this.types.get(fqn)
const existingPosition = this.positionsByType.get(fqn)
let type = null
if (description.alias) {
type = new Alias(this, fqn, description, existing)
} else {
type = new Struct(this, fqn, description, existing)
}
this.types.set(fqn, type)
const json = type.toJSON()
if (existing) {
this.schema[existingPosition] = json
} else {
const position = this.schema.push(json) - 1
this.positionsByType.set(fqn, position)
this.typesByPosition.set(position, fqn)
}
return type
}
namespace (name) {
if (this.namespaces.has(name)) throw new Error('Namespace already exists')
const ns = new HyperschemaNamespace(this, name)
this.namespaces.set(name, ns)
return ns
}
resolve (fqn, { aliases = true } = {}) {
if (Primitive.AllPrimitives.has(fqn)) return Primitive.AllPrimitives.get(fqn)
const type = this.types.get(fqn)
if (!aliases && type.isAlias) return type.type
return type
}
toJSON () {
const json = { version: this.version, schema: this.schema.filter(t => !t.derived) }
return json
}
toCode () {
return generateCode(this)
}
static toDisk (hyperschema, dir) {
if (!dir) dir = hyperschema.dir
fs.mkdirSync(dir, { recursive: true })
const jsonPath = p.join(p.resolve(dir), JSON_FILE_NAME)
const codePath = p.join(p.resolve(dir), CODE_FILE_NAME)
fs.writeFileSync(jsonPath, JSON.stringify(hyperschema.toJSON(), null, 2), { encoding: 'utf-8' })
fs.writeFileSync(codePath, hyperschema.toCode(), { encoding: 'utf-8' })
}
static from (json) {
if (typeof json === 'string') {
const jsonFilePath = p.join(p.resolve(json), JSON_FILE_NAME)
let exists = false
try {
fs.statSync(jsonFilePath)
exists = true
} catch (err) {
if (err.code !== 'ENOENT') throw err
}
if (exists) return new this(JSON.parse(fs.readFileSync(jsonFilePath)), { dir: json })
return new this(null, { dir: json })
}
return new this(json)
}
}