@prestack/thread 0.1.21 → 0.1.22

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 (137) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/dist/core/attach.js +21 -0
  3. package/dist/core/descend.js +6 -3
  4. package/dist/core/generators/commit.js +55 -3
  5. package/dist/core/generators/dev.d.ts +1 -0
  6. package/dist/core/generators/dev.js +68 -0
  7. package/dist/core/tree.d.ts +1 -0
  8. package/dist/core/tree.js +7 -1
  9. package/dist/derivation/generators/build.d.ts +3 -1
  10. package/dist/derivation/generators/build.js +103 -22
  11. package/dist/derivation/generators/release.js +74 -26
  12. package/dist/derivation/release.js +3 -3
  13. package/dist/integrations/cli.js +69 -10
  14. package/dist/integrations/pglite.js +13 -3
  15. package/dist/integrations/target.d.ts +3 -0
  16. package/dist/integrity/attach.js +1 -5
  17. package/dist/integrity/slopnators/sl_stale_path.js +10 -0
  18. package/dist/operations/access.js +3 -2
  19. package/dist/operations/capabilities.d.ts +2 -0
  20. package/dist/operations/capabilities.js +2 -0
  21. package/dist/operations/generators/capability.js +234 -24
  22. package/dist/operations/generators/compile.js +150 -50
  23. package/dist/operations/generators/seed.d.ts +3 -0
  24. package/dist/operations/generators/seed.js +25 -3
  25. package/dist/operations/nodes.d.ts +2 -2
  26. package/package.json +1 -1
  27. package/dist/core/actor.ts +0 -21
  28. package/dist/core/attach.ts +0 -613
  29. package/dist/core/chain.ts +0 -145
  30. package/dist/core/condition.ts +0 -227
  31. package/dist/core/descend.ts +0 -1185
  32. package/dist/core/errors/catalogue.ts +0 -314
  33. package/dist/core/errors/classes.ts +0 -51
  34. package/dist/core/errors/error.ts +0 -152
  35. package/dist/core/errors/namespaces.ts +0 -19
  36. package/dist/core/finding.ts +0 -37
  37. package/dist/core/generators/commit.ts +0 -157
  38. package/dist/core/init.ts +0 -222
  39. package/dist/core/invoked.ts +0 -52
  40. package/dist/core/round.ts +0 -74
  41. package/dist/core/rows.ts +0 -506
  42. package/dist/core/scope.ts +0 -20
  43. package/dist/core/thread.ts +0 -29
  44. package/dist/core/tree.ts +0 -90
  45. package/dist/core/types.ts +0 -351
  46. package/dist/derivation/attach.ts +0 -57
  47. package/dist/derivation/generators/build.ts +0 -371
  48. package/dist/derivation/generators/check.ts +0 -118
  49. package/dist/derivation/generators/determinism.ts +0 -145
  50. package/dist/derivation/generators/release.ts +0 -236
  51. package/dist/derivation/release.ts +0 -43
  52. package/dist/integrations/attach.ts +0 -187
  53. package/dist/integrations/cli.ts +0 -606
  54. package/dist/integrations/declared.ts +0 -34
  55. package/dist/integrations/disk.ts +0 -466
  56. package/dist/integrations/generators/read.ts +0 -127
  57. package/dist/integrations/generators/vault.ts +0 -210
  58. package/dist/integrations/https.ts +0 -216
  59. package/dist/integrations/pglite.ts +0 -206
  60. package/dist/integrations/secret.ts +0 -54
  61. package/dist/integrations/target.ts +0 -672
  62. package/dist/integrations/vault.ts +0 -148
  63. package/dist/integrity/attach.ts +0 -150
  64. package/dist/integrity/check-threads.ts +0 -166
  65. package/dist/integrity/constraint.ts +0 -1246
  66. package/dist/integrity/generators/compiler.ts +0 -138
  67. package/dist/integrity/generators/gate.ts +0 -227
  68. package/dist/integrity/generators/issues.ts +0 -160
  69. package/dist/integrity/generators/publish.ts +0 -233
  70. package/dist/integrity/generators/trial.ts +0 -359
  71. package/dist/integrity/lint.ts +0 -214
  72. package/dist/integrity/lsp/bin.ts +0 -44
  73. package/dist/integrity/lsp/diagnostics.ts +0 -92
  74. package/dist/integrity/lsp/findings.ts +0 -63
  75. package/dist/integrity/lsp/lint.ts +0 -124
  76. package/dist/integrity/lsp/protocol.ts +0 -78
  77. package/dist/integrity/lsp/server.ts +0 -224
  78. package/dist/integrity/slopnator.ts +0 -324
  79. package/dist/integrity/slopnators/sl_broken_lineage.ts +0 -38
  80. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +0 -60
  81. package/dist/integrity/slopnators/sl_derived_drift.ts +0 -101
  82. package/dist/integrity/slopnators/sl_door_in_a_node.ts +0 -250
  83. package/dist/integrity/slopnators/sl_hardcoded_style.ts +0 -55
  84. package/dist/integrity/slopnators/sl_inert_island.ts +0 -84
  85. package/dist/integrity/slopnators/sl_inert_slopnator.ts +0 -93
  86. package/dist/integrity/slopnators/sl_missing_row.ts +0 -140
  87. package/dist/integrity/slopnators/sl_no_barrel.ts +0 -48
  88. package/dist/integrity/slopnators/sl_no_child_process.ts +0 -100
  89. package/dist/integrity/slopnators/sl_no_comment.ts +0 -43
  90. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +0 -276
  91. package/dist/integrity/slopnators/sl_no_import_cycle.ts +0 -66
  92. package/dist/integrity/slopnators/sl_no_such_command.ts +0 -81
  93. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +0 -63
  94. package/dist/integrity/slopnators/sl_outside_an_operation.ts +0 -25
  95. package/dist/integrity/slopnators/sl_screen_holds_only_declared.ts +0 -70
  96. package/dist/integrity/slopnators/sl_secret_literal.ts +0 -68
  97. package/dist/integrity/slopnators/sl_stale_path.ts +0 -178
  98. package/dist/integrity/slopnators/sl_twice_meant.ts +0 -57
  99. package/dist/integrity/slopnators/sl_twice_parented.ts +0 -82
  100. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +0 -481
  101. package/dist/integrity/slopnators/sl_undeclared_destination.ts +0 -113
  102. package/dist/integrity/slopnators/sl_undeclared_element.ts +0 -44
  103. package/dist/integrity/slopnators/sl_undeclared_list.ts +0 -358
  104. package/dist/integrity/slopnators/sl_undeclared_node.ts +0 -75
  105. package/dist/integrity/slopnators/sl_undeclared_operation.ts +0 -257
  106. package/dist/integrity/slopnators/sl_undeclared_variant.ts +0 -152
  107. package/dist/integrity/slopnators/sl_undeclared_webhook.ts +0 -108
  108. package/dist/integrity/slopnators/sl_underived_route.ts +0 -72
  109. package/dist/integrity/slopnators/sl_undescribed.ts +0 -65
  110. package/dist/integrity/slopnators/sl_ungoverned_island.ts +0 -60
  111. package/dist/integrity/slopnators/sl_unlisted_program.ts +0 -46
  112. package/dist/integrity/slopnators/sl_unnamed_island.ts +0 -41
  113. package/dist/integrity/slopnators/sl_unpaired_member.ts +0 -139
  114. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +0 -71
  115. package/dist/integrity/slopnators/sl_unprefixed_member.ts +0 -110
  116. package/dist/integrity/slopnators/sl_unthreaded.ts +0 -421
  117. package/dist/integrity/slopnators/sl_untranslated_key.ts +0 -140
  118. package/dist/integrity/slopnators/sl_unvaulted_secret.ts +0 -132
  119. package/dist/integrity/slopnators/sl_unversioned.ts +0 -52
  120. package/dist/integrity/sweep.ts +0 -395
  121. package/dist/knowledge/attach.ts +0 -327
  122. package/dist/knowledge/generators/docsgen.ts +0 -186
  123. package/dist/knowledge/generators/glossary.ts +0 -73
  124. package/dist/knowledge/glossary.ts +0 -6
  125. package/dist/operations/access.ts +0 -131
  126. package/dist/operations/attach.ts +0 -408
  127. package/dist/operations/capabilities.ts +0 -88
  128. package/dist/operations/dispatch.ts +0 -294
  129. package/dist/operations/error-results.ts +0 -31
  130. package/dist/operations/generators/capability.ts +0 -1054
  131. package/dist/operations/generators/compile.ts +0 -701
  132. package/dist/operations/generators/permits.ts +0 -229
  133. package/dist/operations/generators/seed.ts +0 -359
  134. package/dist/operations/generators/trial.ts +0 -666
  135. package/dist/operations/nodes.ts +0 -53
  136. package/dist/operations/operations.ts +0 -91
  137. package/dist/operations/result.ts +0 -28
@@ -1,210 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from '../disk.ts'
2
- import { valuesIn } from '../../core/rows.ts'
3
- import { dirname, join, relative } from 'node:path'
4
- import { argv, stderr, stdout } from 'node:process'
5
-
6
- import ts from 'typescript'
7
-
8
- import { exit } from '../../core/invoked.ts'
9
- import { named as packageOf } from '../../core/scope.ts'
10
- import { findTree, inTree, strayFiles } from '../../core/tree.ts'
11
-
12
- import { unclassified, valued, type Secret } from '../secret.ts'
13
- import { refusals, servesBrowser, type Call, type Integration, type Target } from '../target.ts'
14
- import { ALGORITHM, KEY_BYTES, KEY_NAME, SEALED_AT, SEALED_NAME, VAULT_DIR } from '../vault.ts'
15
-
16
- const TREE = findTree()
17
-
18
- const AT = 'src/vault.ts'
19
-
20
- const here = (one: string): string => relative(TREE.at, one).split('\\').join('/')
21
-
22
- const from = inTree(TREE, 'secrets.ts')
23
-
24
- const path = join(TREE.at, AT)
25
-
26
- const declares = relative(dirname(path), from).split('\\').join('/')
27
-
28
- const sheet = declares.startsWith('.') ? declares : `./${declares}`
29
-
30
- function refuse(because: string): never {
31
- stderr.write(`vault: ${because}\n`)
32
- exit(1)
33
- }
34
-
35
- let secrets: readonly Secret[] = []
36
- try {
37
- const taken = valuesIn(readFileSync(from)) as { SECRETS?: readonly Secret[] }
38
- secrets = taken.SECRETS ?? []
39
- } catch {
40
- refuse(`${here(from)} could not be read. Attach \`${packageOf('integrations')}\` first — an app states its secrets there.`)
41
- }
42
-
43
- const declared = secrets.map((one) => one.id).sort()
44
-
45
- const SOURCE = [
46
- "import { createDecipheriv } from 'node:crypto'",
47
- "import { existsSync, readFileSync } from 'node:fs'",
48
- "import { join } from 'node:path'",
49
- "",
50
- `import { SECRETS } from '${sheet}'`,
51
- "",
52
- "const ENV = process.env.NODE_ENV === 'production' ? 'production' : 'sandbox'",
53
- "",
54
- "export async function vault(refs: readonly string[]): Promise<Readonly<Record<string, string>>> {",
55
- " if (refs.length === 0) return {}",
56
- ` const at = join(process.cwd(), '${VAULT_DIR}', '${KEY_NAME}')`,
57
- " if (!existsSync(at)) throw new Error('there is no vault key at ' + at + ', and nothing here can open a sealed value without it.')",
58
- " const key = Buffer.from(readFileSync(at, 'utf8').trim(), 'base64')",
59
- ` if (key.length !== ${String(KEY_BYTES)}) throw new Error('the vault key at ' + at + ' is not the size anything here was sealed under.')`,
60
- ` const where = join(process.cwd(), '${VAULT_DIR}', '${SEALED_NAME}')`,
61
- " if (!existsSync(where)) throw new Error('there is no sealed store at ' + where + ', so no declared secret has a value yet.')",
62
- " const store = JSON.parse(readFileSync(where, 'utf8')) as Readonly<Record<string, Readonly<Record<string, string>> | undefined>>",
63
- " const known = SECRETS.map((one) => one.id)",
64
- " const out: Record<string, string> = {}",
65
- " for (const named of refs) {",
66
- " if (!known.includes(named)) throw new Error(named + ' is not a secret this app declares.')",
67
- " const sealed = store[named]?.[ENV]",
68
- " if (sealed === undefined) throw new Error(named + ' is declared and has no value sealed for ' + ENV + '.')",
69
- " const parts = sealed.split('.').map((one) => Buffer.from(one, 'base64'))",
70
- " const iv = parts[0]",
71
- " const tag = parts[1]",
72
- " const body = parts[2]",
73
- " if (iv === undefined || tag === undefined || body === undefined) throw new Error(named + ' is not three base64 parts.')",
74
- ` const decipher = createDecipheriv('${ALGORITHM}', key, iv)`,
75
- " decipher.setAuthTag(tag)",
76
- " out[named] = decipher.update(body).toString('utf8') + decipher.final('utf8')",
77
- " }",
78
- " return out",
79
- "}",
80
- "",
81
- ].join("\n")
82
-
83
- async function taken<T>(file: string, list: string): Promise<readonly T[]> {
84
- const at = inTree(TREE, file)
85
- if (!existsSync(at)) return []
86
- try {
87
- const held = valuesIn(readFileSync(at)) as Record<string, readonly T[] | undefined>
88
- return held[list] ?? []
89
- } catch {
90
- return []
91
- }
92
- }
93
-
94
- function imported(at: string, text: string): readonly string[] {
95
- const source = ts.createSourceFile(at, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
96
- const found: string[] = []
97
- const walk = (node: ts.Node): void => {
98
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
99
- const said = node.moduleSpecifier
100
- if (said !== undefined && ts.isStringLiteral(said)) found.push(said.text)
101
- } else if (ts.isCallExpression(node)) {
102
- const first = node.arguments[0]
103
- const dynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword
104
- const legacy = ts.isIdentifier(node.expression) && node.expression.text === 'require'
105
- if ((dynamic || legacy) && first !== undefined && ts.isStringLiteral(first)) found.push(first.text)
106
- }
107
- ts.forEachChild(node, walk)
108
- }
109
- walk(source)
110
- return found
111
- }
112
-
113
- function names(specifier: string, id: string): boolean {
114
- const bare = specifier.replace(/\.(?:m|c)?[jt]sx?$/, '')
115
- return bare === `operations/${id}` || bare.endsWith(`/operations/${id}`)
116
- }
117
-
118
- async function reaching(): Promise<readonly string[]> {
119
- const targets = await taken<Target>('targets.ts', 'TARGETS')
120
- const withheld = new Map<string, string>()
121
- for (const one of targets) {
122
- const said = servesBrowser(one, secrets)
123
- if (said !== true) withheld.set(one.id, said)
124
- }
125
- if (withheld.size === 0) return []
126
-
127
- const operations = await taken<{ readonly id: string; readonly requires?: readonly string[] }>('operations.ts', 'MEMBERS')
128
- const risky = new Map<string, string>()
129
- for (const one of operations) {
130
- for (const word of one.requires ?? []) {
131
- const said = withheld.get(word)
132
- if (said !== undefined) risky.set(one.id, said)
133
- }
134
- }
135
- if (risky.size === 0) return []
136
-
137
- const found: string[] = []
138
- for (const one of strayFiles(TREE.at, '', '.tsx')) {
139
- let text: string
140
- try {
141
- text = readFileSync(join(TREE.at, one), 'utf8')
142
- } catch {
143
- continue
144
- }
145
- const specifiers = imported(one, text)
146
- for (const [id, said] of risky) {
147
- if (!specifiers.some((each) => names(each, id))) continue
148
- found.push(
149
- `${one} is a \`.tsx\` file and it imports \`${id}\`. ${said} This holds whoever bundles it — ` +
150
- 'a page is a page whether Next, Vite or anything else compiles it, so the refusal is about the ' +
151
- 'import rather than about any one build. Call it from a server and let the page ask the server',
152
- )
153
- }
154
- }
155
- return found
156
- }
157
-
158
- async function stated(): Promise<readonly string[]> {
159
- const targets = await taken<Target>('targets.ts', 'TARGETS')
160
- const integrations = await taken<Integration>('targets.ts', 'INTEGRATIONS')
161
- const calls = await taken<Call>('targets.ts', 'CALLS')
162
- const entities = (await taken<{ readonly id: string }>('entities.ts', 'ENTITIES')).map((one) => one.id)
163
- return [
164
- ...unclassified(secrets),
165
- ...valued(secrets),
166
- ...refusals(targets, secrets, calls, entities, integrations),
167
- ...(await reaching()),
168
- ]
169
- }
170
-
171
- const checking = argv.includes('--check')
172
-
173
- const opening = `${String(declared.length)} declared secret(s)`
174
-
175
- if (checking) {
176
- const wrong = await stated()
177
- if (wrong.length > 0) {
178
- refuse(
179
- [
180
- `${String(wrong.length)} thing(s) this app states about what it reaches will not hold:`,
181
- ...wrong.map((one) => ` ${one}`),
182
- ].join('\n'),
183
- )
184
- }
185
-
186
- const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
187
- if (current !== SOURCE) {
188
- refuse(
189
- current === ''
190
- ? `${AT} is declared and not on disk, and it is what opens this app's sealed values. Run \`thread vault\` and commit what it writes.`
191
- : `${AT} is not what the secrets in ${here(from)} produce. A derived file that differs from its generator is a hand edit wearing a generated name. Run \`thread vault\` and commit what it writes.`,
192
- )
193
- }
194
- stdout.write(`vault: ${AT} is seeded and opens ${opening}, read off ${here(from)} as they are declared.\n`)
195
- exit(0)
196
- }
197
-
198
- if (existsSync(path) && readFileSync(path, 'utf8') === SOURCE) {
199
- stdout.write(`vault: ${AT} is already yours — it opens ${opening}, read off ${here(from)} as they are declared.\n`)
200
- exit(0)
201
- }
202
-
203
- writeFileSync(path, SOURCE, 'utf8')
204
- stdout.write(
205
- `vault: wrote ${AT} — what opens a secret's value at the moment of use.\n` +
206
- ` It opens the values sealed under ${SEALED_AT} for the secrets declared in ${here(from)}, and refuses every other name.\n` +
207
- ' `thread create secret` declares one and seals its value; nothing else ever takes a value.\n' +
208
- ' It imports nothing of this framework: what a host runs holds none of the scaffolding that assembled it.\n',
209
- )
210
- exit(0)
@@ -1,216 +0,0 @@
1
- import type { Http, HttpRequest, HttpResponse } from './target.ts'
2
- import { ValidationError } from '../operations/result.ts'
3
-
4
- export const HTTP_CALL_ACTION = 'http.call'
5
-
6
- export class HttpNotConfiguredError extends Error {
7
- constructor() {
8
- super(
9
- 'nothing here sends. An app hands its operations the transport an outward call leaves through, and this one was handed none. ' +
10
- "Run thread capabilities to write the file that hands it on; which hosts may be reached is declared in the tree and is not the transport's business.",
11
- )
12
- this.name = 'HttpNotConfiguredError'
13
- }
14
- }
15
-
16
- const beneath = (thrown: unknown): string => {
17
- const under = (thrown as { readonly cause?: { readonly code?: unknown; readonly message?: unknown } } | undefined)?.cause
18
- if (under === undefined || under === null) return ''
19
- return ' (' + [under.code, under.message].filter((one) => typeof one === 'string' && one !== '').join(': ') + ')'
20
- }
21
-
22
- export class OutwardTransportError extends Error {
23
- constructor(
24
- readonly target: string,
25
- cause: unknown,
26
- ) {
27
- super(`outward call to "${target}" never reached it: ${cause instanceof Error ? cause.message : String(cause)}${beneath(cause)}`)
28
- this.name = 'OutwardTransportError'
29
- }
30
- }
31
-
32
- export const refusedPath = (path: string): string | undefined =>
33
- path.startsWith('/')
34
- ? `a call's path is written from its target's own address onward, and "${path}" opens with a slash — write it without one`
35
- : undefined
36
-
37
- const LOOPBACK: readonly string[] = ['localhost', '127.0.0.1', '::1']
38
-
39
- export function outwardUrl(target: string, address: string, path: string): URL {
40
- let base: URL
41
- try {
42
- base = new URL(address)
43
- } catch {
44
- throw new ValidationError(`the address resolved for "${target}" is not an absolute URL: ${JSON.stringify(address)}`)
45
- }
46
- if (base.protocol !== 'https:' && !(base.protocol === 'http:' && LOOPBACK.includes(base.hostname))) {
47
- throw new ValidationError(
48
- `outward calls go over https, and "${target}" resolved to ${base.protocol.replace(':', '')} at ${base.hostname}. ` +
49
- `A server on this machine — ${LOOPBACK.join(', ')} — may answer over http, because nothing leaves the machine to reach it.`,
50
- )
51
- }
52
- if (base.search !== '' || base.hash !== '') {
53
- throw new ValidationError(`the address for "${target}" carries a query or a fragment — those belong to the call`)
54
- }
55
- const said = refusedPath(path)
56
- if (said !== undefined) {
57
- throw new ValidationError(said)
58
- }
59
- const url = new URL(`${base.origin}${base.pathname.replace(/\/$/, '')}/${path}`)
60
- if (url.origin !== base.origin) {
61
- throw new ValidationError(`the path "${path}" resolves to ${url.origin}, which is not where "${target}" is`)
62
- }
63
- return url
64
- }
65
-
66
- function wireBody(body: Uint8Array): ArrayBuffer {
67
- const copy = new ArrayBuffer(body.byteLength)
68
- new Uint8Array(copy).set(body)
69
- return copy
70
- }
71
-
72
- export interface HttpOptions {
73
- readonly timeout?: number
74
- }
75
-
76
- const DEFAULT_TIMEOUT_MS = 10_000
77
-
78
- export const STREAMED = 'application/x-ndjson'
79
-
80
- export const TOLD = 'told'
81
-
82
- async function heard(from: ReadableStream<Uint8Array>, told: (said: unknown) => void): Promise<Uint8Array> {
83
- const reader = from.getReader()
84
- const decoder = new TextDecoder()
85
- let pending = ''
86
- let last = ''
87
- const line = (one: string): void => {
88
- if (one.trim() === '') return
89
- const held = JSON.parse(one) as Record<string, unknown>
90
- if (typeof held === 'object' && held !== null && TOLD in held) told(held[TOLD])
91
- else last = one
92
- }
93
- for (;;) {
94
- const { done, value } = await reader.read()
95
- if (done) break
96
- pending += decoder.decode(value, { stream: true })
97
- let at = pending.indexOf('\n')
98
- while (at !== -1) {
99
- line(pending.slice(0, at))
100
- pending = pending.slice(at + 1)
101
- at = pending.indexOf('\n')
102
- }
103
- }
104
- line(pending + decoder.decode())
105
- return new TextEncoder().encode(last)
106
- }
107
-
108
- export async function called(request: HttpRequest, options: HttpOptions = {}): Promise<HttpResponse> {
109
- const send = fetch
110
- const within = options.timeout ?? DEFAULT_TIMEOUT_MS
111
-
112
- const url = outwardUrl(request.target, request.address, request.path)
113
-
114
- const headers: Record<string, string> = { ...request.headers }
115
- if (request.contentType !== undefined) headers['content-type'] = request.contentType
116
- if (request.idempotencyKey !== undefined) headers['idempotency-key'] = request.idempotencyKey
117
- if (request.told !== undefined) headers['accept'] = STREAMED
118
-
119
- let response: Response
120
- try {
121
- response = await send(url.toString(), {
122
- method: request.method,
123
- headers,
124
- ...(request.body === undefined ? {} : { body: wireBody(request.body) }),
125
- signal: AbortSignal.timeout(within),
126
- })
127
- } catch (error) {
128
- throw new OutwardTransportError(request.target, error)
129
- }
130
-
131
- const told = request.told
132
- const stream = response.body
133
- const streamed = (response.headers.get('content-type') ?? '').includes(STREAMED)
134
- let body: Uint8Array
135
- try {
136
- body = told !== undefined && stream !== null && streamed ? await heard(stream, told) : new Uint8Array(await response.arrayBuffer())
137
- } catch (error) {
138
- throw new OutwardTransportError(request.target, error)
139
- }
140
-
141
- return {
142
- status: response.status,
143
- headers: Object.fromEntries(response.headers.entries()),
144
- body,
145
- }
146
- }
147
-
148
- export function sendingHttp(options: HttpOptions = {}): Http {
149
- return { call: (request, within) => called(request, within === undefined ? options : { ...options, timeout: within }) }
150
- }
151
-
152
- export interface RecordedCall {
153
- readonly target: string
154
- readonly method: string
155
- readonly path: string
156
- readonly headers: Readonly<Record<string, string>>
157
- readonly body: string
158
- }
159
-
160
- export interface ScriptedAnswer {
161
- readonly status: number
162
- readonly headers?: Readonly<Record<string, string>>
163
- readonly body?: string
164
- }
165
-
166
- const REDACTED = '(sent)'
167
-
168
- export type Scripted = ScriptedAnswer | readonly ScriptedAnswer[]
169
-
170
- export function recordingHttp(
171
- script: Readonly<Record<string, Scripted>> = {},
172
- ): Http & { readonly calls: readonly RecordedCall[] } {
173
- const calls: RecordedCall[] = []
174
- const decoder = new TextDecoder()
175
- const spent = new Map<string, number>()
176
-
177
- return {
178
- calls,
179
- async call(request) {
180
- outwardUrl(request.target, request.address, request.path)
181
-
182
- const body = request.body === undefined ? '' : decoder.decode(request.body)
183
- calls.push({
184
- target: request.target,
185
- method: request.method,
186
- path: request.path,
187
- headers: Object.fromEntries(Object.keys(request.headers ?? {}).map((one) => [one, REDACTED])),
188
- body,
189
- })
190
-
191
- const key = `${request.method} ${request.path}`
192
- const held = script[key]
193
- if (held === undefined) {
194
- throw new Error(
195
- `this scenario scripts no answer for "${key}", so nothing here knows what ${request.target} would say. Add that key to the scenario's answers — a call answered by something nobody scripted proves it was made and nothing about what came back.`,
196
- )
197
- }
198
- const each = Array.isArray(held) ? (held as readonly ScriptedAnswer[]) : undefined
199
- const at = spent.get(key) ?? 0
200
- spent.set(key, at + 1)
201
- const answer = each === undefined ? (held as ScriptedAnswer) : each[at]
202
- if (answer === undefined) {
203
- throw new Error(
204
- `this scenario scripts ${String(each?.length ?? 0)} answer(s) for "${key}" and it has now been called ${String(at + 1)} time(s). A node that calls the same thing again needs one answer per pass, written in the order they come back.`,
205
- )
206
- }
207
- const answered = new TextEncoder().encode(answer.body ?? '')
208
- const headers = answer.headers ?? {}
209
- return {
210
- status: answer.status,
211
- headers,
212
- body: answered,
213
- }
214
- },
215
- }
216
- }
@@ -1,206 +0,0 @@
1
- import type { Actor } from '../core/actor.ts'
2
- import type { FieldList, Row, Store } from './target.ts'
3
-
4
- const SHAPE = `
5
- select table_name, column_name, data_type, is_nullable
6
- from information_schema.columns
7
- where table_schema = 'public'
8
- order by table_name, column_name
9
- `
10
-
11
- const CONSTRAINTS = `
12
- select tc.table_name, tc.constraint_type, kcu.column_name
13
- from information_schema.table_constraints tc
14
- left join information_schema.key_column_usage kcu on kcu.constraint_name = tc.constraint_name
15
- where tc.table_schema = 'public'
16
- order by tc.table_name, tc.constraint_type, kcu.column_name
17
- `
18
-
19
- interface Queried {
20
- exec(sql: string): Promise<unknown>
21
- query(sql: string, params?: readonly unknown[]): Promise<{ rows: readonly unknown[] }>
22
- close(): Promise<void>
23
- }
24
-
25
- const MISSING =
26
- 'a postgres living inside this process needs `@electric-sql/pglite`, and it is not installed. No package here ' +
27
- 'declares it, because nothing needs one until a target is declared that keeps rows in one — install it in the ' +
28
- 'app that declares that target. Carrying on without having opened one would be the drift this exists to catch, ' +
29
- 'wearing a green tick'
30
-
31
- async function opened(at?: string): Promise<Queried> {
32
- let made: { PGlite: new (at?: string) => Queried }
33
- try {
34
- made = (await import('@electric-sql/pglite')) as unknown as { PGlite: new (at?: string) => Queried }
35
- } catch {
36
- throw new Error(MISSING)
37
- }
38
- return new made.PGlite(at)
39
- }
40
-
41
- const APPLIED = '"_thread_applied"'
42
-
43
- async function caughtUp(made: Queried, history: readonly { readonly name: string; readonly sql: string }[]): Promise<void> {
44
- if (history.length === 0) return
45
- await made.exec('create table if not exists ' + APPLIED + ' ("name" text primary key)')
46
- const done = new Set(
47
- (await made.query('select "name" from ' + APPLIED)).rows.map((one) => String((one as { readonly name?: unknown }).name)),
48
- )
49
- for (const one of history) {
50
- if (done.has(one.name)) continue
51
- await made.exec(one.sql)
52
- await made.query('insert into ' + APPLIED + ' ("name") values ($1)', [one.name])
53
- }
54
- }
55
-
56
- export async function shapeOf(sql: readonly string[]): Promise<string> {
57
- const db = await opened()
58
- try {
59
- for (const one of sql) await db.exec(one)
60
- const columns = await db.query(SHAPE)
61
- const constraints = await db.query(CONSTRAINTS)
62
- return JSON.stringify({ columns: columns.rows, constraints: constraints.rows }, null, 2)
63
- } finally {
64
- await db.close()
65
- }
66
- }
67
-
68
- export interface Kept {
69
-
70
- readonly name: string
71
-
72
- readonly schema: string
73
-
74
- readonly tables: Readonly<Record<string, string>>
75
-
76
- readonly address: Readonly<Record<string, string>>
77
-
78
- readonly columns: Readonly<Record<string, readonly string[]>>
79
-
80
- readonly at: string
81
-
82
- readonly migrations?: readonly { readonly name: string; readonly sql: string }[]
83
- }
84
-
85
- export interface Stood {
86
-
87
- readonly keeps: readonly string[]
88
-
89
- readonly store: Store
90
-
91
- seed(actor: Actor, rows: readonly { readonly entity: string; readonly row: Row }[]): Promise<void>
92
-
93
- taken(actor: Actor): Promise<Readonly<Record<string, readonly Row[]>>>
94
- }
95
-
96
- export function standing(held: Omit<Kept, 'at'>): Stood {
97
- const store = storing({ ...held, at: 'memory://', migrations: [] })
98
- const keeps = Object.keys(held.tables)
99
- return {
100
- keeps,
101
- store,
102
- async seed(actor, rows) {
103
- for (const one of rows) await store.insert(actor, one.entity, one.row)
104
- },
105
- async taken(actor) {
106
- const found: Record<string, readonly Row[]> = {}
107
- for (const entity of keeps) found[entity] = await store.list(actor, entity)
108
- return found
109
- },
110
- }
111
- }
112
-
113
- export function storing(held: Kept): Store {
114
- const q = (name: string): string => JSON.stringify(name)
115
- const rowOf = (one: unknown): Row => one as Row
116
- const known = (entity: string): readonly string[] => held.columns[entity] ?? []
117
-
118
- const tableFor = (entity: string): string => {
119
- const found = held.tables[entity]
120
- if (found === undefined) throw new Error(entity + ' is not kept in ' + held.name + ', so it has no table here')
121
- return found
122
- }
123
-
124
- const addressOf = (entity: string): string => {
125
- const found = held.address[entity]
126
- if (found === undefined) {
127
- throw new Error(
128
- entity + ' declares nothing that identifies one of its rows, so a row of it cannot be addressed here. ' +
129
- 'Give it a value and say so with `thread create entity edit`.',
130
- )
131
- }
132
- return found
133
- }
134
-
135
- let opening: Promise<Queried> | undefined
136
-
137
- const db = async (): Promise<Queried> => {
138
- opening ??= (async () => {
139
- const made = await opened(held.at)
140
- await caughtUp(made, held.migrations ?? [])
141
- await made.exec(held.schema)
142
- return made
143
- })()
144
- return await opening
145
- }
146
-
147
- return {
148
- async insert(_actor, entity, row) {
149
- const cols = known(entity)
150
- const taken = cols.filter((one) => (row as Record<string, unknown>)[one] !== undefined)
151
- const answered = await (await db()).query(
152
- 'insert into ' + q(tableFor(entity)) + ' (' + taken.map(q).join(', ') + ') values (' +
153
- taken.map((_, at) => '$' + String(at + 1)).join(', ') + ') returning *',
154
- taken.map((one) => (row as Record<string, unknown>)[one]),
155
- )
156
- return rowOf(answered.rows[0])
157
- },
158
-
159
- async get(_actor, entity, key, fields: FieldList) {
160
- const cols = known(entity)
161
- const asked = fields.filter((one) => cols.includes(one as string)).map((one) => String(one))
162
- const answered = await (await db()).query(
163
- 'select ' + (asked.length === 0 ? '*' : asked.map(q).join(', ')) + ' from ' + q(tableFor(entity)) +
164
- ' where ' + q(addressOf(entity)) + ' = $1 limit 1',
165
- [key],
166
- )
167
- return answered.rows.length === 0 ? null : rowOf(answered.rows[0])
168
- },
169
-
170
- async list(_actor, entity, opts) {
171
- const cols = known(entity)
172
- const asked = Object.entries(opts?.where ?? {}).filter(([one]) => cols.includes(one))
173
- const answered = await (await db()).query(
174
- 'select * from ' + q(tableFor(entity)) +
175
- (asked.length === 0 ? '' : ' where ' + asked.map(([one], at) => q(one) + ' = $' + String(at + 1)).join(' and ')) +
176
- (opts?.limit === undefined ? '' : ' limit ' + String(opts.limit)),
177
- asked.map(([, one]) => one),
178
- )
179
- return answered.rows.map(rowOf)
180
- },
181
-
182
- async update(_actor, entity, key, patch, expectedVersion) {
183
- if (expectedVersion !== undefined) {
184
- throw new Error(
185
- held.name + ' keeps what ' + entity + ' declares and nothing else, so there is no version on a row to ' +
186
- 'check an expected one against. Declare a value for it and compare that, or update without a version.',
187
- )
188
- }
189
- const cols = known(entity)
190
- const taken = Object.entries(patch).filter(([one]) => cols.includes(one))
191
- if (taken.length === 0) throw new Error('nothing in this patch is a value ' + entity + ' declares')
192
- const answered = await (await db()).query(
193
- 'update ' + q(tableFor(entity)) + ' set ' +
194
- taken.map(([one], at) => q(one) + ' = $' + String(at + 1)).join(', ') +
195
- ' where ' + q(addressOf(entity)) + ' = $' + String(taken.length + 1) + ' returning *',
196
- [...taken.map(([, one]) => one), key],
197
- )
198
- if (answered.rows.length === 0) throw new Error('record ' + String(key) + ' not found')
199
- return rowOf(answered.rows[0])
200
- },
201
-
202
- async remove(_actor, entity, key) {
203
- await (await db()).query('delete from ' + q(tableFor(entity)) + ' where ' + q(addressOf(entity)) + ' = $1', [key])
204
- },
205
- }
206
- }
@@ -1,54 +0,0 @@
1
- import type { Member } from '../core/thread.ts'
2
-
3
- export const SECRECY = {
4
- server: 'sec_',
5
- } as const satisfies Readonly<Record<string, string>>
6
-
7
- export const SAID: Readonly<Record<keyof typeof SECRECY, string>> = {
8
- server:
9
- 'A value only a server may ever hold: an API key, a connection string, a signing key. Every secret is one of these. A value anybody may read is not a secret and is never sealed — the address of a place this app calls and a header every call carries are written on the target row, and only a credential goes into the vault. Everything a browser bundle reaches is in the bundle and belongs to whoever opens the page, so a target requiring a secret is refused a browser entry when it is built rather than when somebody reads it.',
10
- }
11
-
12
- export interface Vault {
13
-
14
- resolve(refs: readonly string[]): Promise<Readonly<Record<string, string>>>
15
- }
16
-
17
- export interface Secret extends Member {
18
-
19
- readonly sends?: { readonly header: string; readonly prefix?: string }
20
-
21
- readonly signs?: { readonly header: string; readonly prefix?: string }
22
- }
23
-
24
- export function secrecyOf(id: string): keyof typeof SECRECY | undefined {
25
- return (Object.keys(SECRECY) as (keyof typeof SECRECY)[]).find((one) => id.startsWith(SECRECY[one]))
26
- }
27
-
28
- export const isServerOnly = (id: string): boolean => secrecyOf(id) === 'server'
29
-
30
- export const unclassified = (secrets: readonly Secret[]): readonly string[] =>
31
- secrets
32
- .filter((one) => secrecyOf(one.id) === undefined)
33
- .map(
34
- (one) =>
35
- one.id + ' does not wear ' + SECRECY.server + ', and every secret does: that prefix says only a server may ever hold the value. ' +
36
- 'A value anybody may read is not a secret. Write it on the row that needs it, as the address of a target or a header every call carries, and take this row out',
37
- )
38
-
39
- const VALUED = ['value', 'secret', 'key', 'token', 'password', 'env']
40
-
41
- export const valued = (secrets: readonly Secret[]): readonly string[] => {
42
- const found: string[] = []
43
- for (const one of secrets) {
44
- for (const named of VALUED) {
45
- if (!(named in one)) continue
46
- found.push(
47
- `\`${one.id}\` carries a \`${named}\`. A secret row holds a name and a sentence and never a value — this row is ` +
48
- 'read by the descent, by every generator and by anything that prints the tree, while a credential is read by ' +
49
- 'one thing at one moment. Put the value in the sealed store with `thread create secret`',
50
- )
51
- }
52
- }
53
- return found
54
- }