@things-factory/figure-service 10.1.117 → 10.1.122
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/dist-server/routers/figure-package-router.d.ts +7 -0
- package/dist-server/routers/figure-package-router.js +48 -0
- package/dist-server/routers/figure-package-router.js.map +1 -0
- package/dist-server/routes.js +3 -0
- package/dist-server/routes.js.map +1 -1
- package/dist-server/service/figure/figure-history.d.ts +1 -0
- package/dist-server/service/figure/figure-history.js +5 -0
- package/dist-server/service/figure/figure-history.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.js +17 -2
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-namespace.d.ts +17 -0
- package/dist-server/service/figure/figure-namespace.js +48 -0
- package/dist-server/service/figure/figure-namespace.js.map +1 -0
- package/dist-server/service/figure/figure-package-export.d.ts +26 -0
- package/dist-server/service/figure/figure-package-export.js +90 -0
- package/dist-server/service/figure/figure-package-export.js.map +1 -0
- package/dist-server/service/figure/figure-query.d.ts +8 -0
- package/dist-server/service/figure/figure-query.js +61 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.js +1 -1
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-thumbnail-store.d.ts +2 -0
- package/dist-server/service/figure/figure-thumbnail-store.js +2 -1
- package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -1
- package/dist-server/service/figure/figure.d.ts +5 -0
- package/dist-server/service/figure/figure.js +5 -0
- package/dist-server/service/figure/figure.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/scripts/rename-figures-to-full-names.mjs +211 -0
- package/server/routers/figure-package-router.ts +53 -0
- package/server/routes.ts +3 -0
- package/server/service/figure/figure-deprecation-db.test.ts +13 -6
- package/server/service/figure/figure-history.ts +4 -0
- package/server/service/figure/figure-mutation.ts +28 -4
- package/server/service/figure/figure-namespace-db.test.ts +181 -0
- package/server/service/figure/figure-namespace.ts +59 -0
- package/server/service/figure/figure-package-export.ts +116 -0
- package/server/service/figure/figure-proposal-db.test.ts +9 -2
- package/server/service/figure/figure-query.ts +74 -1
- package/server/service/figure/figure-release-record-db.test.ts +13 -6
- package/server/service/figure/figure-rename-db.test.ts +19 -11
- package/server/service/figure/figure-rename.test.ts +12 -12
- package/server/service/figure/figure-rename.ts +1 -1
- package/server/service/figure/figure-thumbnail-db.test.ts +3 -0
- package/server/service/figure/figure-thumbnail-store.ts +4 -1
- package/server/service/figure/figure.ts +8 -0
- package/translations/en.json +10 -1
- package/translations/ko.json +10 -1
- package/coverage/clover.xml +0 -6
- package/coverage/coverage-final.json +0 -1
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -101
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov.info +0 -0
- package/logs/.08636eb59927f12972f6774f5947c8507b3564c2-audit.json +0 -15
- package/logs/application-2026-09-14-23.log +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.122",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,16 +25,18 @@
|
|
|
25
25
|
"test:templates": "node --test scripts/templates.test.mts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@hatiolab/figure-model": "^0.
|
|
28
|
+
"@hatiolab/figure-model": "^0.4.0",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
30
|
"@things-factory/ai-client-base": "^10.1.114",
|
|
31
|
-
"@things-factory/attachment-base": "^10.1.
|
|
32
|
-
"@things-factory/auth-base": "^10.1.
|
|
31
|
+
"@things-factory/attachment-base": "^10.1.122",
|
|
32
|
+
"@things-factory/auth-base": "^10.1.122",
|
|
33
33
|
"@things-factory/env": "^10.1.20",
|
|
34
|
-
"@things-factory/
|
|
34
|
+
"@things-factory/setting-base": "^10.1.122",
|
|
35
|
+
"@things-factory/shell": "^10.1.122",
|
|
35
36
|
"graphql-scalars": "^1.22.4",
|
|
36
37
|
"graphql-upload": "^15.0.2",
|
|
38
|
+
"jszip": "^3.10.1",
|
|
37
39
|
"koa-router": "^7.4.0"
|
|
38
40
|
},
|
|
39
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "a66715e94ef6064cade05229de57d0fe5798d1fb"
|
|
40
42
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* One-off: moves a development database to full figure names (ADR-0114, design figure-library.md §11 · P4).
|
|
4
|
+
*
|
|
5
|
+
* type ROBOT_ARM → robot-arm figures · figure_histories · figures.superseded_by
|
|
6
|
+
* namespace → the given one figures · figure_histories (column added when missing)
|
|
7
|
+
* setting figure-namespace = the given one, for the domain that holds the figures
|
|
8
|
+
* board nodes ROBOT_ARM → {namespace}/robot-arm boards.model · board_histories.model
|
|
9
|
+
*
|
|
10
|
+
* Dry run by default: it prints the plan, the checks and the counts and changes nothing. `--apply` runs it in one
|
|
11
|
+
* transaction and refuses, changing nothing, when a check fails. Not a migration file -- it is run once by hand.
|
|
12
|
+
*
|
|
13
|
+
* node scripts/rename-figures-to-full-names.mjs --db <path/to/db.sqlite> --namespace system [--apply]
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url)
|
|
19
|
+
const Database = require('better-sqlite3')
|
|
20
|
+
const { isFigureNamespace, isFigureType } = await import(require.resolve('@hatiolab/figure-model'))
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2)
|
|
23
|
+
const option = name => {
|
|
24
|
+
const i = args.indexOf(`--${name}`)
|
|
25
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
26
|
+
}
|
|
27
|
+
const dbPath = option('db')
|
|
28
|
+
const namespace = option('namespace')
|
|
29
|
+
const apply = args.includes('--apply')
|
|
30
|
+
|
|
31
|
+
if (!dbPath || !namespace) {
|
|
32
|
+
console.error('usage: --db <path> --namespace <namespace> [--apply]')
|
|
33
|
+
process.exit(2)
|
|
34
|
+
}
|
|
35
|
+
if (!isFigureNamespace(namespace)) {
|
|
36
|
+
console.error(`'${namespace}' is not a namespace (lower-case domain-name form, 63 characters at most)`)
|
|
37
|
+
process.exit(2)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const db = new Database(dbPath, { readonly: !apply, fileMustExist: true })
|
|
41
|
+
const hasTable = name => !!db.prepare(`select 1 from sqlite_master where type='table' and name=?`).get(name)
|
|
42
|
+
const hasColumn = (table, column) => !!db.prepare(`select 1 from pragma_table_info(?) where name=?`).get(table, column)
|
|
43
|
+
|
|
44
|
+
/** An old type in the shape boards used: capitals, digits and underscores. */
|
|
45
|
+
const OLD_TYPE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/
|
|
46
|
+
const kebab = type => type.toLowerCase().replace(/_+/g, '-')
|
|
47
|
+
|
|
48
|
+
/* ── what is there ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
49
|
+
|
|
50
|
+
function counts() {
|
|
51
|
+
const one = sql => db.prepare(sql).pluck().get()
|
|
52
|
+
const ns = (table) => (hasColumn(table, 'namespace') ? one(`select count(*) from ${table} where namespace is null`) : one(`select count(*) from ${table}`))
|
|
53
|
+
return {
|
|
54
|
+
figures: one('select count(*) from figures'),
|
|
55
|
+
figuresLive: one('select count(*) from figures where deleted_at is null'),
|
|
56
|
+
histories: one('select count(*) from figure_histories'),
|
|
57
|
+
figuresOldType: db.prepare('select type from figures').pluck().all().filter(t => OLD_TYPE.test(t)).length,
|
|
58
|
+
historiesOldType: db.prepare('select type from figure_histories').pluck().all().filter(t => t && OLD_TYPE.test(t)).length,
|
|
59
|
+
figuresWithoutNamespace: ns('figures'),
|
|
60
|
+
historiesWithoutNamespace: ns('figure_histories'),
|
|
61
|
+
namespaceSettings: one(`select count(*) from settings where name='figure-namespace'`),
|
|
62
|
+
boardNodesOldType: boardNodes().old,
|
|
63
|
+
boardNodesFullName: boardNodes().full
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Board model nodes naming a figure type of this database, in the old shape or already full. */
|
|
68
|
+
function boardNodes(map = typeMap) {
|
|
69
|
+
let old = 0
|
|
70
|
+
let full = 0
|
|
71
|
+
for (const table of ['boards', 'board_histories']) {
|
|
72
|
+
if (!hasTable(table)) continue
|
|
73
|
+
for (const { model } of db.prepare(`select model from ${table}`).all()) {
|
|
74
|
+
walkTypes(model, type => {
|
|
75
|
+
if (map.has(type)) old++
|
|
76
|
+
else if (type.includes('/') && [...map.values()].some(t => type === `${namespace}/${t}`)) full++
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { old, full }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkTypes(text, visit) {
|
|
84
|
+
let model
|
|
85
|
+
try {
|
|
86
|
+
model = JSON.parse(text || '{}')
|
|
87
|
+
} catch {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const walk = node => {
|
|
91
|
+
for (const child of Array.isArray(node?.components) ? node.components : []) {
|
|
92
|
+
if (typeof child?.type === 'string') visit(child.type, child)
|
|
93
|
+
walk(child)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
walk(model)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ── the plan and its checks ───────────────────────────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
const domains = db.prepare('select distinct domain_id as id from figures').pluck().all()
|
|
102
|
+
const problems = []
|
|
103
|
+
if (domains.length !== 1) problems.push(`figures are in ${domains.length} domains; this script moves one domain`)
|
|
104
|
+
const domainId = domains[0]
|
|
105
|
+
|
|
106
|
+
const rows = db.prepare('select id, type, deleted_at as deletedAt from figures where domain_id = ?').all(domainId)
|
|
107
|
+
const historyTypes = db.prepare('select distinct type from figure_histories where domain_id = ? and type is not null').pluck().all(domainId)
|
|
108
|
+
const typeMap = new Map()
|
|
109
|
+
for (const type of new Set([...rows.map(r => r.type), ...historyTypes])) {
|
|
110
|
+
if (!OLD_TYPE.test(type)) continue
|
|
111
|
+
typeMap.set(type, kebab(type))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Every new name must be a type, and two old names must never land on one new name among the live figures. */
|
|
115
|
+
for (const [from, to] of typeMap) if (!isFigureType(to)) problems.push(`${from} → ${to} is not a figure type`)
|
|
116
|
+
const liveByNew = new Map()
|
|
117
|
+
for (const row of rows.filter(r => !r.deletedAt)) {
|
|
118
|
+
const to = typeMap.get(row.type) ?? row.type
|
|
119
|
+
if (liveByNew.has(to)) problems.push(`${liveByNew.get(to)} and ${row.type} would both become ${to}`)
|
|
120
|
+
else liveByNew.set(to, row.type)
|
|
121
|
+
}
|
|
122
|
+
const deletedClashes = rows.filter(r => r.deletedAt && liveByNew.has(typeMap.get(r.type) ?? r.type) && liveByNew.get(typeMap.get(r.type) ?? r.type) !== r.type)
|
|
123
|
+
|
|
124
|
+
const setting = db.prepare(`select value from settings where name='figure-namespace' and domain_id = ?`).get(domainId)
|
|
125
|
+
if (setting && setting.value !== namespace) problems.push(`the domain setting figure-namespace is already ${setting.value}`)
|
|
126
|
+
if (hasColumn('figures', 'namespace')) {
|
|
127
|
+
const others = db.prepare('select distinct namespace from figures where domain_id = ? and namespace is not null and namespace != ?').pluck().all(domainId, namespace)
|
|
128
|
+
if (others.length) problems.push(`figures already carry another namespace: ${others.join(', ')}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const before = counts()
|
|
132
|
+
console.log(`database ${dbPath}`)
|
|
133
|
+
console.log(`domain ${db.prepare('select name from domains where id = ?').pluck().get(domainId) ?? domainId}`)
|
|
134
|
+
console.log(`namespace ${namespace}`)
|
|
135
|
+
console.log(`renames ${typeMap.size}`)
|
|
136
|
+
for (const [from, to] of [...typeMap].sort()) console.log(` ${from.padEnd(28)} → ${to}`)
|
|
137
|
+
if (deletedClashes.length) console.log(`note: deleted figures sharing a new name with a live one (kept, outside the unique index): ${deletedClashes.map(r => r.type).join(', ')}`)
|
|
138
|
+
console.log('before', before)
|
|
139
|
+
|
|
140
|
+
if (problems.length) {
|
|
141
|
+
console.error('\nrefused, nothing changed:')
|
|
142
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
143
|
+
process.exit(1)
|
|
144
|
+
}
|
|
145
|
+
if (!apply) {
|
|
146
|
+
console.log('\ndry run: nothing changed. run again with --apply.')
|
|
147
|
+
process.exit(0)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── apply, in one transaction ─────────────────────────────────────────────────────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
db.transaction(() => {
|
|
153
|
+
for (const table of ['figures', 'figure_histories']) {
|
|
154
|
+
if (!hasColumn(table, 'namespace')) db.exec(`alter table ${table} add column namespace varchar`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
|
158
|
+
if (!setting) {
|
|
159
|
+
db.prepare(`insert into settings (id, name, description, category, value, created_at, updated_at, domain_id) values (?, 'figure-namespace', 'namespace of the figures made here (ADR-0114)', 'figure', ?, ?, ?, ?)`)
|
|
160
|
+
.run(randomUUID(), namespace, now, now, domainId)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const renameFigure = db.prepare('update figures set type = ? where domain_id = ? and type = ?')
|
|
164
|
+
const renameHistory = db.prepare('update figure_histories set type = ? where domain_id = ? and type = ?')
|
|
165
|
+
const renameSuperseded = db.prepare('update figures set superseded_by = ? where domain_id = ? and superseded_by = ?')
|
|
166
|
+
for (const [from, to] of typeMap) {
|
|
167
|
+
renameFigure.run(to, domainId, from)
|
|
168
|
+
renameHistory.run(to, domainId, from)
|
|
169
|
+
renameSuperseded.run(to, domainId, from)
|
|
170
|
+
}
|
|
171
|
+
db.prepare('update figures set namespace = ? where domain_id = ? and namespace is null').run(namespace, domainId)
|
|
172
|
+
db.prepare('update figure_histories set namespace = ? where domain_id = ? and namespace is null').run(namespace, domainId)
|
|
173
|
+
|
|
174
|
+
for (const table of ['boards', 'board_histories']) {
|
|
175
|
+
if (!hasTable(table)) continue
|
|
176
|
+
const update = db.prepare(`update ${table} set model = ? where id = ?`)
|
|
177
|
+
for (const { id, model } of db.prepare(`select id, model from ${table} where domain_id = ?`).all(domainId)) {
|
|
178
|
+
let changed = false
|
|
179
|
+
let parsed
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(model || '{}')
|
|
182
|
+
} catch {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const walk = node => {
|
|
186
|
+
for (const child of Array.isArray(node?.components) ? node.components : []) {
|
|
187
|
+
if (typeof child?.type === 'string' && typeMap.has(child.type)) {
|
|
188
|
+
child.type = `${namespace}/${typeMap.get(child.type)}`
|
|
189
|
+
changed = true
|
|
190
|
+
}
|
|
191
|
+
walk(child)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
walk(parsed)
|
|
195
|
+
if (changed) update.run(JSON.stringify(parsed), id)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const after = counts()
|
|
200
|
+
const wrong = []
|
|
201
|
+
if (after.figures !== before.figures) wrong.push('figure count changed')
|
|
202
|
+
if (after.histories !== before.histories) wrong.push('history count changed')
|
|
203
|
+
if (after.figuresOldType || after.historiesOldType) wrong.push('old type names remain')
|
|
204
|
+
if (after.figuresWithoutNamespace || after.historiesWithoutNamespace) wrong.push('rows without a namespace remain')
|
|
205
|
+
if (after.namespaceSettings < 1) wrong.push('the namespace setting is missing')
|
|
206
|
+
if (after.boardNodesOldType) wrong.push('board nodes with old names remain')
|
|
207
|
+
console.log('after ', after)
|
|
208
|
+
if (wrong.length) throw new Error(`checks after the change failed, rolled back: ${wrong.join(', ')}`)
|
|
209
|
+
})()
|
|
210
|
+
|
|
211
|
+
console.log('\napplied.')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Router from 'koa-router'
|
|
2
|
+
|
|
3
|
+
import { checkPermission } from '@things-factory/auth-base'
|
|
4
|
+
import { config } from '@things-factory/env'
|
|
5
|
+
import { Refusal, speakRefusal } from '@things-factory/shell'
|
|
6
|
+
|
|
7
|
+
import { figurePackageOf } from '../service/figure/figure-package-export.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Released figures as a package file to download (ADR-0114 결정 3): `GET /figure-package?refs=ns/type@v,...`.
|
|
11
|
+
*
|
|
12
|
+
* The same door as `figureBlueprint` (`figure-published`, domain owners included). The manifest names where the
|
|
13
|
+
* releases can be had again: `figure.publicUrl` in the server config, or this request's origin.
|
|
14
|
+
*/
|
|
15
|
+
export const figurePackageRouter = new Router()
|
|
16
|
+
|
|
17
|
+
figurePackageRouter.get('/figure-package', async context => {
|
|
18
|
+
const { domain, user, unsafeIP, prohibitedPrivileges } = context.state
|
|
19
|
+
const allowed = await checkPermission(
|
|
20
|
+
{ category: 'figure-published', privilege: 'query', owner: true },
|
|
21
|
+
user,
|
|
22
|
+
domain,
|
|
23
|
+
unsafeIP,
|
|
24
|
+
prohibitedPrivileges
|
|
25
|
+
)
|
|
26
|
+
if (!allowed) {
|
|
27
|
+
context.status = 403
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const refs = String(context.query.refs ?? '')
|
|
32
|
+
.split(',')
|
|
33
|
+
.map(ref => ref.trim())
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { name, bytes } = await figurePackageOf({
|
|
38
|
+
refs,
|
|
39
|
+
domain,
|
|
40
|
+
exportedBy: user?.email ?? user?.name ?? null,
|
|
41
|
+
sourceUrl: config.get('figure.publicUrl', context.origin)
|
|
42
|
+
})
|
|
43
|
+
context.set('Content-Disposition', `attachment; filename="${name}"`)
|
|
44
|
+
context.set('Cache-Control', 'private, no-store')
|
|
45
|
+
context.type = 'application/zip'
|
|
46
|
+
context.body = bytes
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!(error instanceof Refusal)) throw error
|
|
49
|
+
const spoken = speakRefusal(error, { t: (context as any).t })
|
|
50
|
+
context.status = 400
|
|
51
|
+
context.body = { code: error.code, params: error.params, message: spoken.message }
|
|
52
|
+
}
|
|
53
|
+
})
|
package/server/routes.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { config } from '@things-factory/env'
|
|
|
2
2
|
import { getRoutePrefixForDomainType } from '@things-factory/shell'
|
|
3
3
|
|
|
4
4
|
import { figureThumbnailRouter } from './routers/figure-thumbnail-router.js'
|
|
5
|
+
import { figurePackageRouter } from './routers/figure-package-router.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* 도메인 경로가 주소에 들어가는 배치인가.
|
|
@@ -18,7 +19,9 @@ process.on('bootstrap-module-domain-private-route' as any, (app, domainPrivateRo
|
|
|
18
19
|
figureThumbnailRouter.routes(),
|
|
19
20
|
figureThumbnailRouter.allowedMethods()
|
|
20
21
|
)
|
|
22
|
+
domainPrivateRouter.use(`/${getRoutePrefixForDomainType()}/:domain`, figurePackageRouter.routes(), figurePackageRouter.allowedMethods())
|
|
21
23
|
} else {
|
|
22
24
|
domainPrivateRouter.use('', figureThumbnailRouter.routes(), figureThumbnailRouter.allowedMethods())
|
|
25
|
+
domainPrivateRouter.use('', figurePackageRouter.routes(), figurePackageRouter.allowedMethods())
|
|
23
26
|
}
|
|
24
27
|
})
|
|
@@ -7,6 +7,7 @@ import type { DataSource } from 'typeorm'
|
|
|
7
7
|
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
8
8
|
const shell = require('@things-factory/shell')
|
|
9
9
|
const authBase = require('@things-factory/auth-base')
|
|
10
|
+
const settingBase = require('@things-factory/setting-base')
|
|
10
11
|
const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy } = require('@hatiolab/figure-model')
|
|
11
12
|
|
|
12
13
|
let dataSource: DataSource | null = null
|
|
@@ -36,7 +37,7 @@ beforeAll(async () => {
|
|
|
36
37
|
synchronize: true,
|
|
37
38
|
dropSchema: true,
|
|
38
39
|
namingStrategy: new shell.NamingStrategy(),
|
|
39
|
-
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory],
|
|
40
|
+
entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
|
|
40
41
|
subscribers: [Subscriber],
|
|
41
42
|
logging: false
|
|
42
43
|
})
|
|
@@ -48,6 +49,12 @@ afterAll(async () => {
|
|
|
48
49
|
dataSource = null
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
/** The domain setting every figure here is created under (ADR-0114). */
|
|
53
|
+
async function namespaced(domain: any) {
|
|
54
|
+
await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: 'system' })
|
|
55
|
+
return domain
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
const releasable = () => {
|
|
52
59
|
let a = addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
53
60
|
a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
@@ -57,9 +64,9 @@ const releasable = () => {
|
|
|
57
64
|
let seq = 0
|
|
58
65
|
async function released(type: string) {
|
|
59
66
|
const name = `retire-${++seq}`
|
|
60
|
-
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
67
|
+
const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
|
|
61
68
|
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
62
|
-
const figure = await dataSource!.getRepository(Figure).save({ type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
69
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type, name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
63
70
|
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
64
71
|
return { figure, context, domain }
|
|
65
72
|
}
|
|
@@ -68,7 +75,7 @@ const historyCount = (id: string) => dataSource!.getRepository(FigureHistory).co
|
|
|
68
75
|
describe('retiring a figure', () => {
|
|
69
76
|
it('stays released, writes no history row, and names what to use instead', async () => {
|
|
70
77
|
const { figure, context, domain } = await released('OLD_MIXER')
|
|
71
|
-
await dataSource!.getRepository(Figure).save({ type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
|
|
78
|
+
await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'NEW_MIXER', name: 'new', source: releasable(), state: 'draft', version: 0, domain })
|
|
72
79
|
const retired = await new FigureMutation().setFigureDeprecation(figure.id, true, context, 'NEW_MIXER')
|
|
73
80
|
expect([retired.deprecated, retired.supersededBy, retired.state, retired.version]).toEqual([true, 'NEW_MIXER', 'released', 1])
|
|
74
81
|
expect(await historyCount(figure.id)).toBe(1)
|
|
@@ -95,9 +102,9 @@ describe('names by language', () => {
|
|
|
95
102
|
|
|
96
103
|
it('kept on the figure and copied onto the release it goes out with', async () => {
|
|
97
104
|
const name = `names-${++seq}`
|
|
98
|
-
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
105
|
+
const domain = await namespaced(await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name }))
|
|
99
106
|
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
100
|
-
const figure = await dataSource!.getRepository(Figure).save({ type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
107
|
+
const figure = await dataSource!.getRepository(Figure).save({ namespace: 'system', type: 'NAMED', name: 'mixer', source: releasable(), state: 'draft', version: 0, domain })
|
|
101
108
|
await new FigureMutation().updateFigure(figure.id, { names: { en: 'Mixer', ko: '믹서' } }, context)
|
|
102
109
|
await new FigureMutation().releaseFigure(figure.id, 'one', context)
|
|
103
110
|
const [row] = await dataSource!.getRepository(FigureHistory).find({ where: { originalId: figure.id } })
|
|
@@ -48,6 +48,10 @@ export class FigureHistory implements HistoryEntityInterface<Figure> {
|
|
|
48
48
|
@Field({ nullable: true, description: 'The stored identifier of the figure. It never changes.' })
|
|
49
49
|
type?: string
|
|
50
50
|
|
|
51
|
+
@Column({ nullable: true })
|
|
52
|
+
@Field({ nullable: true, description: 'Namespace of the full name {namespace}/{type} at this version.' })
|
|
53
|
+
namespace?: string
|
|
54
|
+
|
|
51
55
|
@Column({ nullable: true })
|
|
52
56
|
@Field({ nullable: true, description: 'The display name at this version.' })
|
|
53
57
|
name?: string
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { In } from 'typeorm'
|
|
2
|
-
import { Arg, Ctx, Directive, Mutation, Resolver } from 'type-graphql'
|
|
2
|
+
import { Arg, Ctx, Directive, Mutation, Resolver, UseMiddleware } from 'type-graphql'
|
|
3
3
|
|
|
4
|
-
import { parseFigureAsset, placeholderType } from '@hatiolab/figure-model'
|
|
5
|
-
import { getRepository } from '@things-factory/shell'
|
|
4
|
+
import { isFigureType, parseFigureAsset, placeholderType, TYPE_LENGTH } from '@hatiolab/figure-model'
|
|
5
|
+
import { getRepository, Refusal, SpeakRefusals } from '@things-factory/shell'
|
|
6
6
|
|
|
7
7
|
import { Figure } from './figure.js'
|
|
8
8
|
import { FigureHistory } from './figure-history.js'
|
|
@@ -13,6 +13,7 @@ import { recordProposals } from './figure-proposal.js'
|
|
|
13
13
|
import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
14
14
|
import { inspectSource, releaseRecordOf } from './figure-format.js'
|
|
15
15
|
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
16
|
+
import { namespaceForNewFigure, namespaceOf } from './figure-namespace.js'
|
|
16
17
|
import { parseSource } from './figure-format.js'
|
|
17
18
|
import { ProposeFigureFailure, proposeFigureSteps } from './figure-propose-steps.js'
|
|
18
19
|
import { prepareFigureWrite, compareAndSwapFigure, pictureOf } from './figure-write.js'
|
|
@@ -27,6 +28,7 @@ export function assertNames(names: unknown): asserts names is Record<string, str
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
@Resolver(Figure)
|
|
31
|
+
@UseMiddleware(SpeakRefusals)
|
|
30
32
|
export class FigureMutation {
|
|
31
33
|
@Directive('@transaction')
|
|
32
34
|
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
@@ -82,6 +84,7 @@ export class FigureMutation {
|
|
|
82
84
|
name or not at all -- a taken name refuses the save, rather than saving under the temporary one and failing a
|
|
83
85
|
rename after. Without a name (a caller that has none to give) it is the temporary one, renamed later.
|
|
84
86
|
*/
|
|
87
|
+
const namespace = await namespaceForNewFigure(tx, domain)
|
|
85
88
|
const named = type?.trim()
|
|
86
89
|
if (named) {
|
|
87
90
|
const facts = await renameFacts(
|
|
@@ -93,7 +96,7 @@ export class FigureMutation {
|
|
|
93
96
|
renameDraftType({ type: named, source: prepared.source }, named, facts)
|
|
94
97
|
}
|
|
95
98
|
// insert, not save: a concurrent retry must never turn into an unchecked overwrite.
|
|
96
|
-
await repository.insert({ ...prepared, id: creationId, type: named || placeholderType(),
|
|
99
|
+
await repository.insert({ ...prepared, id: creationId, type: named || placeholderType(), namespace,
|
|
97
100
|
domain, creator: user, updater: user })
|
|
98
101
|
return repository.findOneOrFail({ where: { id: creationId, domain: { id: domain.id } } })
|
|
99
102
|
}
|
|
@@ -118,6 +121,15 @@ export class FigureMutation {
|
|
|
118
121
|
const { domain, user, tx } = context.state
|
|
119
122
|
const repository = tx.getRepository(Figure)
|
|
120
123
|
|
|
124
|
+
if (!isFigureType(figure.type ?? '')) {
|
|
125
|
+
throw new Refusal(
|
|
126
|
+
'figure-type-malformed',
|
|
127
|
+
{ type: figure.type ?? '', min: TYPE_LENGTH.min, max: TYPE_LENGTH.max },
|
|
128
|
+
'{type} is not a figure type. use lower-case letters, digits and single hyphens, {min} to {max} characters, starting with a letter.'
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
const namespace = await namespaceForNewFigure(tx, domain)
|
|
132
|
+
|
|
121
133
|
// 타입 이름은 도메인 안에서 유일하다. 유니크 인덱스가 있지만 여기서 먼저 본다 —
|
|
122
134
|
// DB 오류 문구는 저작자에게 아무 말도 해 주지 못한다.
|
|
123
135
|
const taken = await repository.findOne({ where: { domain: { id: domain.id }, type: figure.type } })
|
|
@@ -133,6 +145,7 @@ export class FigureMutation {
|
|
|
133
145
|
...rest,
|
|
134
146
|
source,
|
|
135
147
|
...derived,
|
|
148
|
+
namespace,
|
|
136
149
|
state: figure.state ?? 'draft',
|
|
137
150
|
...(await pictureOf(tx, domain, user, thumbnail)),
|
|
138
151
|
domain,
|
|
@@ -303,6 +316,17 @@ export class FigureMutation {
|
|
|
303
316
|
throw new Error(`Figure '${figure.type}' 은 이미 발행되어 있다. 고쳐서 다시 내려면 초안으로 되돌린 뒤 발행한다`)
|
|
304
317
|
}
|
|
305
318
|
|
|
319
|
+
/* A release promises boards the full name {namespace}/{type}; it is refused while the domain setting disagrees. */
|
|
320
|
+
const namespace = namespaceOf(figure)
|
|
321
|
+
const current = await namespaceForNewFigure(tx, domain)
|
|
322
|
+
if (current !== namespace) {
|
|
323
|
+
throw new Refusal(
|
|
324
|
+
'figure-namespace-changed',
|
|
325
|
+
{ setting: 'figure-namespace', value: current, namespace },
|
|
326
|
+
'the figure namespace of this domain is {namespace} and cannot change. set {setting} back to {namespace}.'
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
306
330
|
/*
|
|
307
331
|
── 게이트는 여기 있어야 한다 ────────────────────────────────────────────
|
|
308
332
|
화면도 같은 판정을 미리 물어(`inspectFigure`) 이유를 보여 주지만, **막는 자리는 이곳뿐이다.**
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full figure names on the authoring server (ADR-0114): the namespace a figure is created under, the batch door
|
|
3
|
+
* `figureBlueprints`, and the package a board server imports. sqlite, history subscriber on.
|
|
4
|
+
*/
|
|
5
|
+
import type { DataSource } from 'typeorm'
|
|
6
|
+
|
|
7
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
8
|
+
const { createHash } = require('crypto')
|
|
9
|
+
const JSZip = require('jszip')
|
|
10
|
+
const shell = require('@things-factory/shell')
|
|
11
|
+
const authBase = require('@things-factory/auth-base')
|
|
12
|
+
const settingBase = require('@things-factory/setting-base')
|
|
13
|
+
const { createFigureAsset, addFigureBox, serializeFigureAsset, applyFigureAuthoring, proposeFigureOccupancy, readFigurePackage } = require('@hatiolab/figure-model')
|
|
14
|
+
|
|
15
|
+
let dataSource: DataSource | null = null
|
|
16
|
+
let Figure: any
|
|
17
|
+
let FigureMutation: any
|
|
18
|
+
let FigureQuery: any
|
|
19
|
+
let figurePackageOf: any
|
|
20
|
+
|
|
21
|
+
function loadCompiled(modulePath: string): any {
|
|
22
|
+
const { existsSync } = require('fs')
|
|
23
|
+
const { join } = require('path')
|
|
24
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
25
|
+
if (!existsSync(resolved))
|
|
26
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
|
|
27
|
+
return require(resolved)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
32
|
+
const FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
33
|
+
const Subscriber = loadCompiled('service/figure/event-subscriber').FigureHistoryEntitySubscriber
|
|
34
|
+
;({ FigureMutation } = loadCompiled('service/figure/figure-mutation'))
|
|
35
|
+
;({ FigureQuery } = loadCompiled('service/figure/figure-query'))
|
|
36
|
+
;({ figurePackageOf } = loadCompiled('service/figure/figure-package-export'))
|
|
37
|
+
dataSource = new TypeOrmDataSource({
|
|
38
|
+
type: 'sqlite',
|
|
39
|
+
database: ':memory:',
|
|
40
|
+
synchronize: true,
|
|
41
|
+
dropSchema: true,
|
|
42
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
43
|
+
entities: [...shell.entities, ...authBase.entities, ...settingBase.entities, Figure, FigureHistory],
|
|
44
|
+
subscribers: [Subscriber],
|
|
45
|
+
logging: false
|
|
46
|
+
})
|
|
47
|
+
await dataSource.initialize()
|
|
48
|
+
shell.addDataSource('default', dataSource)
|
|
49
|
+
shell.addDataSource('tx', dataSource)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await dataSource?.destroy()
|
|
54
|
+
dataSource = null
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const releasable = (label = 'Conveyor') => {
|
|
58
|
+
let a = addFigureBox(createFigureAsset(label), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } })
|
|
59
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } })
|
|
60
|
+
return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), placement: 'floor' }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let seq = 0
|
|
64
|
+
async function domainWith(namespace: string | null) {
|
|
65
|
+
const name = `ns-${++seq}`
|
|
66
|
+
const domain = await dataSource!.getRepository(shell.Domain).save({ name, subdomain: name })
|
|
67
|
+
if (namespace !== null) {
|
|
68
|
+
await dataSource!.getRepository(settingBase.Setting).save({ domain, name: 'figure-namespace', category: 'figure', value: namespace })
|
|
69
|
+
}
|
|
70
|
+
const context = { state: { domain, user: undefined, tx: dataSource!.manager } }
|
|
71
|
+
return { domain, context }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const creationId = () => require('crypto').randomUUID()
|
|
75
|
+
const setNamespace = (domain: any, value: string) =>
|
|
76
|
+
dataSource!.getRepository(settingBase.Setting).update({ domain: { id: domain.id }, name: 'figure-namespace' }, { value })
|
|
77
|
+
|
|
78
|
+
async function releasedFigure(context: any, type: string) {
|
|
79
|
+
const figure = await new FigureMutation().saveFigure(releasable(type), context, undefined, undefined, creationId(), undefined, type)
|
|
80
|
+
await new FigureMutation().releaseFigure(figure.id, 'first', context)
|
|
81
|
+
return figure
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('the namespace a figure is created under', () => {
|
|
85
|
+
it('is copied from the domain setting onto a new figure', async () => {
|
|
86
|
+
const { context } = await domainWith('hatiolab.com')
|
|
87
|
+
const figure = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
|
|
88
|
+
expect(figure.namespace).toBe('hatiolab.com')
|
|
89
|
+
expect(figure.type).toBe('conveyor')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('is refused by name when the domain has none, or an invalid one', async () => {
|
|
93
|
+
for (const value of [null, 'Hatiolab.COM']) {
|
|
94
|
+
const { context } = await domainWith(value)
|
|
95
|
+
await expect(
|
|
96
|
+
new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
|
|
97
|
+
).rejects.toMatchObject({ code: 'figure-namespace-missing' })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('cannot change: a new figure or a release is refused while the setting disagrees with the figures already made', async () => {
|
|
102
|
+
const { domain, context } = await domainWith('hatiolab.com')
|
|
103
|
+
const draft = await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'conveyor')
|
|
104
|
+
await setNamespace(domain, 'other.com')
|
|
105
|
+
await expect(
|
|
106
|
+
new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'lifter')
|
|
107
|
+
).rejects.toMatchObject({ code: 'figure-namespace-changed' })
|
|
108
|
+
await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).rejects.toMatchObject({ code: 'figure-namespace-changed' })
|
|
109
|
+
|
|
110
|
+
await setNamespace(domain, 'hatiolab.com')
|
|
111
|
+
await expect(new FigureMutation().releaseFigure(draft.id, 'c', context)).resolves.toMatchObject({ version: 1 })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('refuses an upper-case type when creating directly', async () => {
|
|
115
|
+
const { context } = await domainWith('hatiolab.com')
|
|
116
|
+
await expect(
|
|
117
|
+
new FigureMutation().createFigure({ type: 'CONVEYOR', name: 'c', source: releasable() }, context)
|
|
118
|
+
).rejects.toMatchObject({ code: 'figure-type-malformed' })
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('figureBlueprints answers each full name, an item or a refusal', () => {
|
|
123
|
+
it('gives released figures by full name and refuses the rest by cause, none taking another away', async () => {
|
|
124
|
+
const { context } = await domainWith('hatiolab.com')
|
|
125
|
+
await releasedFigure(context, 'conveyor')
|
|
126
|
+
await new FigureMutation().saveFigure(releasable(), context, undefined, undefined, creationId(), undefined, 'draft-only')
|
|
127
|
+
|
|
128
|
+
const answer = await new FigureQuery().figureBlueprints(
|
|
129
|
+
['hatiolab.com/conveyor', 'hatiolab.com/draft-only', 'other.com/conveyor', 'CONVEYOR', 'hatiolab.com/conveyor'],
|
|
130
|
+
context
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(answer.release).toBeNull()
|
|
134
|
+
expect(answer.items.map((item: any) => [item.type, item.ref])).toEqual([['hatiolab.com/conveyor', 'hatiolab.com/conveyor@1']])
|
|
135
|
+
expect(answer.items[0].asset.version).toBe('figure-asset-1')
|
|
136
|
+
expect(answer.refused.map((r: any) => [r.type, r.code])).toEqual([
|
|
137
|
+
['hatiolab.com/draft-only', 'figure-not-released'],
|
|
138
|
+
['other.com/conveyor', 'figure-not-released'],
|
|
139
|
+
['CONVEYOR', 'figure-name-malformed']
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('the figure package', () => {
|
|
145
|
+
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
146
|
+
|
|
147
|
+
it('carries the release a board server reads back whole, and names the server it came from', async () => {
|
|
148
|
+
const { domain, context } = await domainWith('hatiolab.com')
|
|
149
|
+
const figure = await releasedFigure(context, 'trailer-22')
|
|
150
|
+
|
|
151
|
+
const { name, bytes } = await figurePackageOf({
|
|
152
|
+
refs: ['hatiolab.com/trailer-22@1'],
|
|
153
|
+
domain,
|
|
154
|
+
exportedBy: 'author',
|
|
155
|
+
sourceUrl: 'https://figure.hatiolab.com',
|
|
156
|
+
files: { put: async () => ({}), send: async () => {}, read: async () => Buffer.alloc(0) }
|
|
157
|
+
})
|
|
158
|
+
expect(name).toBe('hatiolab.com_trailer-22_1.figpkg')
|
|
159
|
+
|
|
160
|
+
const zip = await JSZip.loadAsync(bytes)
|
|
161
|
+
const files = new Map<string, Uint8Array>()
|
|
162
|
+
for (const path of Object.keys(zip.files)) if (!zip.files[path].dir) files.set(path, await zip.files[path].async('uint8array'))
|
|
163
|
+
const read = readFigurePackage(files, sha256)
|
|
164
|
+
|
|
165
|
+
expect(read.problems).toEqual([])
|
|
166
|
+
expect(read.refused).toEqual([])
|
|
167
|
+
expect(read.manifest.source).toEqual({ url: 'https://figure.hatiolab.com', domain: domain.subdomain })
|
|
168
|
+
expect(read.releases.map((r: any) => r.ref)).toEqual(['hatiolab.com/trailer-22@1'])
|
|
169
|
+
const stored = await dataSource!.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
|
|
170
|
+
expect(read.releases[0].source).toBe(stored.source)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('refuses a release this domain cannot read, and a malformed reference', async () => {
|
|
174
|
+
const { domain, context } = await domainWith('hatiolab.com')
|
|
175
|
+
await releasedFigure(context, 'conveyor')
|
|
176
|
+
const ask = (refs: string[]) => figurePackageOf({ refs, domain, exportedBy: null, sourceUrl: 'x' })
|
|
177
|
+
await expect(ask(['hatiolab.com/conveyor@2'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
|
|
178
|
+
await expect(ask(['other.com/conveyor@1'])).rejects.toMatchObject({ code: 'figure-package-release-not-found' })
|
|
179
|
+
await expect(ask(['CONVEYOR@1'])).rejects.toMatchObject({ code: 'figure-ref-malformed' })
|
|
180
|
+
})
|
|
181
|
+
})
|