@crombolo/server 0.0.0-stage → 0.10.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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Bundles: the definitions, and the npm package a download builds from one.
3
+ * `docs/bundles.md`.
4
+ *
5
+ * Nothing new is serialized here. Schema travels as the canonical TOML
6
+ * `@crombolo/schema` already writes, content and media as the bundle
7
+ * `@crombolo/transfer` already exports, files as themselves, and dictionary
8
+ * items as the `.udt` the dictionary already exports. The build is composition,
9
+ * which is why a bundle can carry everything the builder UI offers.
10
+ */
11
+ import { join } from 'node:path'
12
+ import {
13
+ type BuiltPackage,
14
+ type FileSystemPort,
15
+ type PackageDefinition,
16
+ type PackageDefinitionInput,
17
+ type PackagePort,
18
+ type PackageWriteResult,
19
+ packageFileName,
20
+ } from '@crombolo/api-management'
21
+ import { ObjectTypes, type Page } from '@crombolo/core'
22
+ import {
23
+ type Bundle,
24
+ BundleRepository,
25
+ type ComponentFileStore,
26
+ ComponentRepository,
27
+ currentSchemaState,
28
+ type Db,
29
+ DictionaryRepository,
30
+ type NodeRow,
31
+ resolveNodeRef,
32
+ } from '@crombolo/data'
33
+ import {
34
+ exportSchemaSet,
35
+ fileNameFor,
36
+ type SchemaSet,
37
+ writeContentType,
38
+ writeDataType,
39
+ writeLanguages,
40
+ writeMediaType,
41
+ writeSchemaVersion,
42
+ } from '@crombolo/schema'
43
+ import {
44
+ BUNDLE_SECTIONS,
45
+ type BundleBlob,
46
+ type BundleDependency,
47
+ type BundleFile,
48
+ type BundleKind,
49
+ type BundleNode,
50
+ type BundleSection,
51
+ bundleIdFor,
52
+ type ContentSet,
53
+ exportBundle,
54
+ isPackageName,
55
+ packageNameFor,
56
+ writeBundle,
57
+ writeBundlePackage,
58
+ } from '@crombolo/transfer'
59
+ import { VERSION } from '../config.ts'
60
+ import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
61
+ import { readBlobs } from '../media-files.ts'
62
+ import type { MediaStore } from '../media-store.ts'
63
+ import { writeNpmTarball } from '../tar.ts'
64
+
65
+ /** The npm package a definition is built as: what was set on it, or what its name gives. */
66
+ export function bundlePackageOf(row: Bundle): { name: string; version: string } {
67
+ return {
68
+ name: row.packageName ?? packageNameFor(row.name),
69
+ version: row.packageVersion ?? '1.0.0',
70
+ }
71
+ }
72
+
73
+ /** The content kinds a bundle's picks can name. */
74
+ const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
75
+
76
+ export interface PackagePortOptions {
77
+ siteName: string
78
+ nodeId: string
79
+ marketplaceUrl: string
80
+ /** Reads a template's view, which lives on disk rather than in the row. */
81
+ componentFiles: ComponentFileStore
82
+ mediaStore: MediaStore
83
+ components?: FileSystemPort
84
+ stylesheets?: FileSystemPort
85
+ scripts?: FileSystemPort
86
+ }
87
+
88
+ const definition = (row: Bundle): PackageDefinition => ({
89
+ id: row.id,
90
+ name: row.name,
91
+ contentNodeId: row.contentNodeId,
92
+ contentLoadChildNodes: row.contentLoadChildNodes,
93
+ mediaIds: row.mediaIds,
94
+ mediaLoadChildNodes: row.mediaLoadChildNodes,
95
+ elementIds: row.elementIds,
96
+ contentTypes: row.contentTypes,
97
+ mediaTypes: row.mediaTypes,
98
+ dataTypes: row.dataTypes,
99
+ // The wire format is Umbraco's, and it still has two fields. There is one
100
+ // list on this side, so it answers as `templates` and `partialViews` is
101
+ // always empty — the split is a property of the contract, not of the data.
102
+ templates: row.components,
103
+ partialViews: [],
104
+ stylesheets: row.stylesheets,
105
+ scripts: row.scripts,
106
+ languages: row.languages,
107
+ dictionaryItems: row.dictionaryItems,
108
+ })
109
+
110
+ /**
111
+ * The contract's two lists, stored as the one this side has.
112
+ *
113
+ * `templates` and `partialViews` are both components; which tree the backoffice
114
+ * picked one from is not something worth keeping.
115
+ */
116
+ const stored = <T extends PackageDefinitionInput>(input: T) => ({
117
+ ...input,
118
+ components: [...input.templates, ...input.partialViews],
119
+ })
120
+
121
+ /** Matches a selection against a type's key or its alias, since the UI sends either. */
122
+ const picks = (selected: readonly string[]) => {
123
+ const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
124
+ return (type: { key?: string; alias: string }): boolean =>
125
+ wanted.has(type.alias.toLowerCase()) ||
126
+ (type.key !== undefined && wanted.has(type.key.toLowerCase()))
127
+ }
128
+
129
+ /** The selected slice of the site's schema, as the files `schema/` would hold. */
130
+ function schemaEntries(set: SchemaSet, from: PackageDefinition): BundleFile[] {
131
+ const entries: BundleFile[] = []
132
+ const put = (path: string, text: string) =>
133
+ entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
134
+
135
+ const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
136
+ const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
137
+ // Member types have no picker in the builder, and the contract's definition
138
+ // has no field for them, so a bundle never carries one.
139
+ const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
140
+ const languages = set.languages.filter((language) =>
141
+ from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
142
+ )
143
+
144
+ if (
145
+ contentTypes.length === 0 &&
146
+ mediaTypes.length === 0 &&
147
+ dataTypes.length === 0 &&
148
+ languages.length === 0
149
+ )
150
+ return entries
151
+
152
+ put('schema.toml', writeSchemaVersion(set.version))
153
+ for (const type of contentTypes)
154
+ put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
155
+ for (const type of mediaTypes)
156
+ put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
157
+ for (const type of dataTypes)
158
+ put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
159
+ if (languages.length > 0) put('languages.toml', writeLanguages(languages))
160
+ return entries
161
+ }
162
+
163
+ /**
164
+ * Several exports as one bundle: the nodes deduped by key, the counts summed,
165
+ * and the dependencies and selector unioned, so the manifest describes what the
166
+ * bundle actually holds rather than what one of the exports held. `writeBundle`
167
+ * recomputes the integrity hash over the files it writes.
168
+ */
169
+ function mergeContentSets(sets: readonly ContentSet[]): ContentSet | undefined {
170
+ const [first, ...rest] = sets
171
+ if (!first) return undefined
172
+ if (rest.length === 0) return first
173
+
174
+ const nodes = new Map<string, BundleNode>()
175
+ for (const set of sets) for (const node of set.nodes) nodes.set(node.key, node)
176
+
177
+ const counts: Partial<Record<BundleKind, number>> = {}
178
+ for (const node of nodes.values()) counts[node.kind] = (counts[node.kind] ?? 0) + 1
179
+
180
+ const expected = new Map<string, BundleDependency>()
181
+ const blobs = new Map<string, BundleBlob>()
182
+ const carried = new Set<string>()
183
+ const contentTypes = new Map<string, { key: string; alias: string }>()
184
+ const components = new Set<string>()
185
+ const languages = new Set<string>()
186
+ const roots: string[] = []
187
+ const asGiven: string[] = []
188
+ for (const set of sets) {
189
+ for (const dependency of set.manifest.dependencies.expected)
190
+ expected.set(dependency.key, dependency)
191
+ for (const key of set.manifest.dependencies.carried) carried.add(key)
192
+ for (const type of set.manifest.dependencies.schema.contentTypes)
193
+ contentTypes.set(type.key, type)
194
+ for (const alias of set.manifest.dependencies.schema.components) components.add(alias)
195
+ for (const iso of set.manifest.dependencies.schema.languages) languages.add(iso)
196
+ for (const blob of set.manifest.blobs) blobs.set(blob.key, blob)
197
+ roots.push(...set.manifest.selector.roots)
198
+ asGiven.push(...set.manifest.selector.asGiven)
199
+ }
200
+
201
+ return {
202
+ nodes: [...nodes.values()],
203
+ manifest: {
204
+ ...first.manifest,
205
+ counts,
206
+ // True of the merged set: a group that asked for descendants is already
207
+ // expanded into its nodes, so the flag has done its work.
208
+ selector: { roots, asGiven, descendants: sets.some((s) => s.manifest.selector.descendants) },
209
+ dependencies: {
210
+ carried: [...carried],
211
+ expected: [...expected.values()],
212
+ schema: {
213
+ contentTypes: [...contentTypes.values()],
214
+ components: [...components],
215
+ languages: [...languages],
216
+ },
217
+ },
218
+ blobs: [...blobs.values()],
219
+ },
220
+ }
221
+ }
222
+
223
+ export function createPackagePort(db: Db, options: PackagePortOptions): PackagePort {
224
+ const bundles = new BundleRepository(db)
225
+
226
+ const validate = (input: PackageDefinitionInput): PackageWriteResult | undefined =>
227
+ input.name.length === 0 ? { ok: false, status: 'InvalidName' } : undefined
228
+
229
+ /**
230
+ * The files a selection of file-system paths resolves to, under one section.
231
+ *
232
+ * The section is a logical name — `styles`, not `css` — because a site holds
233
+ * these wherever it is configured to, and the importer is what maps a section
234
+ * onto a directory.
235
+ */
236
+ const fileEntries = async (
237
+ port: FileSystemPort | undefined,
238
+ paths: readonly string[],
239
+ section: BundleSection,
240
+ ): Promise<BundleFile[]> => {
241
+ if (!port) return []
242
+ const files: BundleFile[] = []
243
+ for (const path of paths) {
244
+ const file = await port.read(path)
245
+ if (!file) continue
246
+ const relative = file.path.replace(/^\/+/, '')
247
+ files.push({ path: `${BUNDLE_SECTIONS[section]}/${relative}`, text: file.content })
248
+ }
249
+ return files
250
+ }
251
+
252
+ const componentEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
253
+ if (keys.length === 0) return []
254
+ const templates = new ComponentRepository(db, options.componentFiles)
255
+ const all = await templates.all()
256
+ const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
257
+ const files: BundleFile[] = []
258
+ for (const template of all) {
259
+ if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
260
+ continue
261
+ files.push({
262
+ path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
263
+ text: template.content ?? '',
264
+ })
265
+ }
266
+ return files
267
+ }
268
+
269
+ /**
270
+ * The components a bundle carries, from one list that holds two kinds of
271
+ * name.
272
+ *
273
+ * The contract has `templates` and `partialViews`; this side has one list, so
274
+ * a picked component arrives either as a template alias — a row in
275
+ * `template`, whose file is read through the alias — or as a path in the
276
+ * components tree. Both are tried and the results are keyed by path, so a
277
+ * component picked both ways is carried once.
278
+ */
279
+ const componentSection = async (picked: readonly string[]): Promise<BundleFile[]> => {
280
+ const byPath = new Map<string, BundleFile>()
281
+ for (const file of await componentEntries(picked)) byPath.set(file.path, file)
282
+ for (const file of await fileEntries(options.components, picked, 'components'))
283
+ byPath.set(file.path, file)
284
+ return [...byPath.values()]
285
+ }
286
+
287
+ const dictionaryEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
288
+ if (keys.length === 0) return []
289
+ const repo = new DictionaryRepository(db)
290
+ const items = []
291
+ for (const key of keys) items.push(...(await dictionaryToUdt(repo, key)))
292
+ if (items.length === 0) return []
293
+ return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
294
+ }
295
+
296
+ /**
297
+ * The content, media and element picks as one transfer bundle, blobs included.
298
+ *
299
+ * Three groups, not one: the contract carries `contentLoadChildNodes` and
300
+ * `mediaLoadChildNodes` as separate booleans, and elements have no such flag
301
+ * at all. `descendants` is per export, so a definition whose two flags
302
+ * disagree needs an export each — otherwise picking a media folder's children
303
+ * would quietly drag a content page's children in too. The sets are merged
304
+ * back into one bundle, which is what the destination reads.
305
+ */
306
+ const contentEntries = async (
307
+ from: PackageDefinition,
308
+ ): Promise<{ set: ContentSet | undefined; blobs: ReadonlyMap<string, Uint8Array> }> => {
309
+ const groups: Array<{ refs: string[]; descendants: boolean }> = [
310
+ {
311
+ refs: from.contentNodeId ? [from.contentNodeId] : [],
312
+ descendants: from.contentLoadChildNodes,
313
+ },
314
+ { refs: from.mediaIds, descendants: from.mediaLoadChildNodes },
315
+ { refs: from.elementIds ?? [], descendants: false },
316
+ ]
317
+
318
+ const sets: ContentSet[] = []
319
+ const blobKeys = new Set<string>()
320
+ for (const group of groups) {
321
+ if (group.refs.length === 0) continue
322
+ const roots: NodeRow[] = []
323
+ for (const ref of group.refs) {
324
+ const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
325
+ // A pick that has since been deleted is dropped rather than failing the
326
+ // download: the definition outlives the content it names.
327
+ if (resolved.ok) roots.push(resolved.node)
328
+ }
329
+ if (roots.length === 0) continue
330
+ const exported = await exportBundle(db, {
331
+ roots,
332
+ asGiven: group.refs,
333
+ descendants: group.descendants,
334
+ snapshot: 'published',
335
+ blueprints: false,
336
+ withBlobs: true,
337
+ siteName: options.siteName,
338
+ nodeId: options.nodeId,
339
+ })
340
+ sets.push(exported.set)
341
+ for (const key of exported.blobKeys) blobKeys.add(key)
342
+ }
343
+
344
+ const merged = mergeContentSets(sets)
345
+ if (!merged) return { set: undefined, blobs: new Map() }
346
+
347
+ const { bytes } =
348
+ blobKeys.size > 0
349
+ ? await readBlobs(options.mediaStore, [...blobKeys])
350
+ : { bytes: new Map<string, Uint8Array>() }
351
+ return { set: merged, blobs: bytes }
352
+ }
353
+
354
+ return {
355
+ marketplaceUrl: () => options.marketplaceUrl,
356
+
357
+ async list(paging): Promise<Page<PackageDefinition>> {
358
+ const result = await bundles.list(paging)
359
+ return { total: result.total, items: result.items.map(definition) }
360
+ },
361
+
362
+ async byId(id) {
363
+ const row = await bundles.byId(id)
364
+ return row ? definition(row) : undefined
365
+ },
366
+
367
+ async create(input, id) {
368
+ const invalid = validate(input)
369
+ if (invalid) return invalid
370
+ if (await bundles.byName(input.name)) return { ok: false, status: 'DuplicateName' }
371
+ const row = await bundles.create(stored(input), id)
372
+ return { ok: true, id: row.id }
373
+ },
374
+
375
+ async update(id, input) {
376
+ const invalid = validate(input)
377
+ if (invalid) return invalid
378
+ if (!(await bundles.byId(id))) return { ok: false, status: 'NotFound' }
379
+ const clash = await bundles.byName(input.name)
380
+ if (clash && clash.id !== id) return { ok: false, status: 'DuplicateName' }
381
+ await bundles.update(id, stored(input))
382
+ return { ok: true, id }
383
+ },
384
+
385
+ async remove(id) {
386
+ if (!(await bundles.byId(id))) return 'notFound'
387
+ await bundles.delete(id)
388
+ return 'deleted'
389
+ },
390
+
391
+ /**
392
+ * The definition as one bundle, packed as the npm package it is.
393
+ *
394
+ * Everything goes through `writeBundle` so there is a single manifest over
395
+ * the whole payload: it declares the sections, carries the hash that covers
396
+ * them, and names the bundle. The package around it is what gives it a
397
+ * name, a version and the core it was written for, so the download is
398
+ * something `bun add ./file.tgz` and `bun publish` take as it is.
399
+ */
400
+ async build(id): Promise<BuiltPackage | undefined> {
401
+ const row = await bundles.byId(id)
402
+ if (!row) return undefined
403
+ const from = definition(row)
404
+ const pkg = bundlePackageOf(row)
405
+
406
+ // The schema version the site is actually at, so the files a package
407
+ // carries say where they came from rather than claiming version 0.
408
+ const state = await currentSchemaState(db).catch(() => undefined)
409
+ const schema = await exportSchemaSet(db, state?.version ?? '0')
410
+
411
+ const sections: BundleFile[] = [
412
+ ...schemaEntries(schema, from),
413
+ ...(await componentSection([...from.templates, ...from.partialViews])),
414
+ ...(await fileEntries(options.stylesheets, from.stylesheets, 'styles')),
415
+ ...(await fileEntries(options.scripts, from.scripts, 'scripts')),
416
+ ...(await dictionaryEntries(from.dictionaryItems)),
417
+ ]
418
+
419
+ const content = await contentEntries(from)
420
+ // A package with nothing picked is still a bundle: an empty selection
421
+ // produces an empty set rather than throwing, which is what gives a
422
+ // schema-only package a manifest of its own.
423
+ const set =
424
+ content.set ??
425
+ (
426
+ await exportBundle(db, {
427
+ roots: [],
428
+ asGiven: [],
429
+ descendants: false,
430
+ snapshot: 'published',
431
+ blueprints: false,
432
+ withBlobs: false,
433
+ siteName: options.siteName,
434
+ nodeId: options.nodeId,
435
+ })
436
+ ).set
437
+
438
+ const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
439
+ const encoder = new TextEncoder()
440
+ const files = writeBundlePackage(writeBundle(labelled, content.blobs, sections), {
441
+ name: pkg.name,
442
+ version: pkg.version,
443
+ id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
444
+ label: from.name,
445
+ peerRange: `^${VERSION}`,
446
+ })
447
+ const entries = files.map((file) => ({
448
+ path: file.path,
449
+ bytes: file.bytes ?? encoder.encode(file.text ?? ''),
450
+ }))
451
+ return {
452
+ fileName: packageFileName(pkg.name, pkg.version),
453
+ contentType: 'application/gzip',
454
+ bytes: writeNpmTarball(entries),
455
+ }
456
+ },
457
+ }
458
+ }
459
+
460
+ /** A created bundle's npm package as the Created view edits it. */
461
+ export interface CreatedBundlePackage {
462
+ name: string
463
+ version: string
464
+ /** Whether each was set, rather than derived from the definition's name. */
465
+ set: { name: boolean; version: boolean }
466
+ }
467
+
468
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
469
+
470
+ /** The npm package a definition builds as, or undefined when it is gone. */
471
+ export async function readCreatedBundlePackage(
472
+ db: Db,
473
+ id: string,
474
+ ): Promise<CreatedBundlePackage | undefined> {
475
+ const row = await new BundleRepository(db).byId(id)
476
+ if (!row) return undefined
477
+ return {
478
+ ...bundlePackageOf(row),
479
+ set: { name: row.packageName !== null, version: row.packageVersion !== null },
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Sets the npm package a definition builds as. An empty value goes back to its
485
+ * default; anything else must be what npm itself would accept.
486
+ */
487
+ export async function writeCreatedBundlePackage(
488
+ db: Db,
489
+ id: string,
490
+ input: { name?: unknown; version?: unknown },
491
+ ): Promise<{ ok: true; package: CreatedBundlePackage } | { ok: false; message: string }> {
492
+ const bundles = new BundleRepository(db)
493
+ if (!(await bundles.byId(id))) return { ok: false, message: 'That bundle no longer exists.' }
494
+ const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
495
+ const name = text(input.name)
496
+ const version = text(input.version)
497
+ if (name && !isPackageName(name))
498
+ return {
499
+ ok: false,
500
+ message:
501
+ 'A package name is lowercase letters, digits, dots, hyphens and underscores, optionally with an @scope/ in front.',
502
+ }
503
+ if (version && !SEMVER.test(version))
504
+ return { ok: false, message: 'A version is three numbers, like 1.0.0.' }
505
+ await bundles.setPackage(id, { name: name || null, version: version || null })
506
+ return { ok: true, package: (await readCreatedBundlePackage(db, id)) as CreatedBundlePackage }
507
+ }