@prestack/thread 0.0.1 → 0.0.2

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 (150) hide show
  1. package/dist/core/actor.ts +20 -0
  2. package/dist/core/asking.d.ts +0 -1
  3. package/dist/core/asking.js +0 -1
  4. package/dist/core/asking.ts +1342 -0
  5. package/dist/core/attach.js +2 -2
  6. package/dist/core/attach.ts +585 -0
  7. package/dist/core/chain.js +8 -1
  8. package/dist/core/chain.ts +148 -0
  9. package/dist/core/cli.js +97 -18
  10. package/dist/core/cli.ts +2004 -0
  11. package/dist/core/condition.ts +227 -0
  12. package/dist/core/descend.d.ts +1 -0
  13. package/dist/core/descend.js +36 -0
  14. package/dist/core/descend.ts +1001 -0
  15. package/dist/core/finding.ts +38 -0
  16. package/dist/core/generators/meta.js +6 -0
  17. package/dist/core/generators/meta.ts +1006 -0
  18. package/dist/core/init.ts +170 -0
  19. package/dist/core/invoked.ts +47 -0
  20. package/dist/core/round.ts +57 -0
  21. package/dist/core/rows.ts +369 -0
  22. package/dist/core/scope.ts +18 -0
  23. package/dist/core/thread.ts +29 -0
  24. package/dist/core/tree.ts +67 -0
  25. package/dist/core/types.ts +350 -0
  26. package/dist/derivation/attach.js +1 -1
  27. package/dist/derivation/attach.ts +68 -0
  28. package/dist/derivation/generators/build.ts +232 -0
  29. package/dist/derivation/generators/check.ts +118 -0
  30. package/dist/derivation/generators/derivation.ts +347 -0
  31. package/dist/derivation/generators/determinism.ts +145 -0
  32. package/dist/derivation/generators/release.js +19 -9
  33. package/dist/derivation/generators/release.ts +213 -0
  34. package/dist/derivation/makers/release.ts +135 -0
  35. package/dist/derivation/release.ts +23 -0
  36. package/dist/integrations/attach.js +2 -2
  37. package/dist/integrations/attach.ts +207 -0
  38. package/dist/integrations/generators/read.ts +127 -0
  39. package/dist/integrations/generators/secret.ts +289 -0
  40. package/dist/integrations/generators/target.js +16 -6
  41. package/dist/integrations/generators/target.ts +944 -0
  42. package/dist/integrations/generators/vault.js +46 -14
  43. package/dist/integrations/generators/vault.ts +212 -0
  44. package/dist/integrations/{sdk.js → module.js} +4 -4
  45. package/dist/integrations/module.ts +189 -0
  46. package/dist/integrations/secret.ts +50 -0
  47. package/dist/integrations/target.d.ts +3 -1
  48. package/dist/integrations/target.js +14 -11
  49. package/dist/integrations/target.ts +340 -0
  50. package/dist/integrations/vault.d.ts +2 -0
  51. package/dist/integrations/vault.js +4 -2
  52. package/dist/integrations/vault.ts +136 -0
  53. package/dist/integrity/attach.ts +234 -0
  54. package/dist/integrity/check-threads.js +2 -26
  55. package/dist/integrity/check-threads.ts +166 -0
  56. package/dist/integrity/constraint.ts +1202 -0
  57. package/dist/integrity/generators/compiler.ts +161 -0
  58. package/dist/integrity/generators/gate.ts +201 -0
  59. package/dist/integrity/generators/issues.ts +160 -0
  60. package/dist/integrity/generators/publish.js +14 -5
  61. package/dist/integrity/generators/publish.ts +247 -0
  62. package/dist/integrity/generators/rule.ts +271 -0
  63. package/dist/integrity/generators/seal.ts +117 -0
  64. package/dist/integrity/generators/trial.ts +320 -0
  65. package/dist/integrity/lint.ts +227 -0
  66. package/dist/integrity/lsp/bin.ts +42 -0
  67. package/dist/integrity/lsp/diagnostics.ts +92 -0
  68. package/dist/integrity/lsp/findings.ts +63 -0
  69. package/dist/integrity/lsp/lint.ts +140 -0
  70. package/dist/integrity/lsp/protocol.ts +78 -0
  71. package/dist/integrity/lsp/server.ts +224 -0
  72. package/dist/integrity/sealed.js +2 -2
  73. package/dist/integrity/sealed.ts +56 -0
  74. package/dist/integrity/slopnator.ts +350 -0
  75. package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
  76. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
  77. package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
  78. package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
  79. package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
  80. package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
  81. package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
  82. package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
  83. package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
  84. package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
  85. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
  86. package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
  87. package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
  88. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
  89. package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
  90. package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
  91. package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
  92. package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
  93. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
  94. package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
  95. package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
  96. package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
  97. package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
  98. package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
  99. package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
  100. package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
  101. package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
  102. package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
  103. package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
  104. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
  105. package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
  106. package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
  107. package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
  108. package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
  109. package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
  110. package/dist/integrity/sweep.ts +307 -0
  111. package/dist/knowledge/attach.ts +350 -0
  112. package/dist/knowledge/generators/docsgen.ts +174 -0
  113. package/dist/knowledge/generators/glossary.ts +73 -0
  114. package/dist/knowledge/generators/skills.ts +263 -0
  115. package/dist/knowledge/glossary.ts +6 -0
  116. package/dist/operations/access.ts +88 -0
  117. package/dist/operations/attach.ts +475 -0
  118. package/dist/operations/capabilities.ts +65 -0
  119. package/dist/operations/cost.ts +58 -0
  120. package/dist/operations/dispatch.ts +348 -0
  121. package/dist/operations/error-results.ts +31 -0
  122. package/dist/operations/errors/catalog.ts +168 -0
  123. package/dist/operations/errors/classes.ts +51 -0
  124. package/dist/operations/errors/errors.ts +314 -0
  125. package/dist/operations/generators/capability.js +29 -19
  126. package/dist/operations/generators/capability.ts +704 -0
  127. package/dist/operations/generators/compile.js +20 -1
  128. package/dist/operations/generators/compile.ts +643 -0
  129. package/dist/operations/generators/grant.ts +336 -0
  130. package/dist/operations/generators/operation.ts +972 -0
  131. package/dist/operations/generators/permits.ts +223 -0
  132. package/dist/operations/generators/seed.ts +347 -0
  133. package/dist/operations/generators/trial.js +4 -1
  134. package/dist/operations/generators/trial.ts +529 -0
  135. package/dist/operations/http.ts +164 -0
  136. package/dist/operations/makers/capability.ts +166 -0
  137. package/dist/operations/makers/trial.ts +520 -0
  138. package/dist/operations/memory.ts +470 -0
  139. package/dist/operations/metering.ts +328 -0
  140. package/dist/operations/nodes.ts +54 -0
  141. package/dist/operations/operations.ts +32 -0
  142. package/dist/operations/queue-policy.ts +10 -0
  143. package/dist/operations/result.ts +28 -0
  144. package/dist/operations/seams.d.ts +4 -4
  145. package/dist/operations/seams.ts +364 -0
  146. package/dist/operations/store.ts +29 -0
  147. package/package.json +8 -1
  148. package/dist/integrations/generators/sdk.d.ts +0 -1
  149. package/dist/integrations/generators/sdk.js +0 -148
  150. /package/dist/integrations/{sdk.d.ts → module.d.ts} +0 -0
@@ -0,0 +1,529 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { argv, stdout } from 'node:process'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { ANONYMOUS, type Actor } from '../../core/actor.ts'
7
+ import { ROOT, TREE, descend } from '../../core/descend.ts'
8
+ import { exit, invoked } from '../../core/invoked.ts'
9
+
10
+ import { recordingHttp, type ScriptedAnswer } from '../http.ts'
11
+ import { inMemorySeams, type Sink } from '../memory.ts'
12
+ import { TRIALS_AT, type Operation } from '../operations.ts'
13
+ import { PERMITS_AT } from '../access.ts'
14
+ import type { Result } from '../result.ts'
15
+ import type { Row, Seams } from '../seams.ts'
16
+ import { operations, types } from './seed.ts'
17
+
18
+ export { TRIALS_AT }
19
+
20
+ const DISPATCH_AT = 'operations/_dispatch.ts'
21
+
22
+ const ENTITIES_AT = `${TREE}entities.ts`
23
+
24
+ const ACCESS_AT = '_threads/access.ts'
25
+
26
+ const WHEN = 1_767_225_600_000
27
+
28
+ export interface Scenario {
29
+ readonly id: string
30
+
31
+ readonly describes: string
32
+
33
+ readonly input: unknown
34
+
35
+ readonly actor?: Actor
36
+
37
+ readonly rows?: readonly { readonly entity: string; readonly row: Row }[]
38
+
39
+ readonly answers?: Readonly<Record<string, ScriptedAnswer>>
40
+
41
+ readonly views?: Readonly<Record<string, readonly unknown[]>>
42
+ }
43
+
44
+ export interface Trial {
45
+ readonly operation: string
46
+ readonly scenario: string
47
+
48
+ readonly refused: boolean
49
+
50
+ readonly measured: boolean
51
+
52
+ readonly result: unknown
53
+
54
+ readonly kept: unknown
55
+
56
+ readonly observed: unknown
57
+
58
+ readonly called: unknown
59
+ }
60
+
61
+ export interface Outcome {
62
+ readonly id: string
63
+ readonly operation: string
64
+ readonly passed: boolean
65
+ readonly refused: boolean
66
+ readonly measured: boolean
67
+ readonly failures: readonly string[]
68
+ }
69
+
70
+ export interface Run {
71
+ readonly outcomes: readonly Outcome[]
72
+ readonly passed: number
73
+ readonly failed: number
74
+ readonly measured: number
75
+ }
76
+
77
+ const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
78
+
79
+ function stable(value: unknown): unknown {
80
+ if (Array.isArray(value)) return value.map(stable)
81
+ if (value instanceof Map) return stable(Object.fromEntries(value))
82
+ if (value instanceof Set) return [...value].map(stable)
83
+ if (typeof value === 'object' && value !== null) {
84
+ const found: Record<string, unknown> = {}
85
+ for (const key of Object.keys(value as Record<string, unknown>).sort(compare)) {
86
+ found[key] = stable((value as Record<string, unknown>)[key])
87
+ }
88
+ return found
89
+ }
90
+ if (typeof value === 'bigint') return String(value)
91
+ if (typeof value === 'function') return '[function]'
92
+ if (value === undefined) return null
93
+ return value
94
+ }
95
+
96
+ const printed = (value: unknown): string => `${JSON.stringify(stable(value), null, 2)}\n`
97
+
98
+ const asking = (scenario: Scenario): Actor => ({
99
+ ...(scenario.actor ?? ANONYMOUS),
100
+ run: 'trial',
101
+ replay: `trial_${scenario.id}`,
102
+ })
103
+
104
+ function seamsFor(
105
+ one: Operation<unknown>,
106
+ scenario: Scenario,
107
+ stood: Readonly<Record<string, string>>,
108
+ gate?: NonNullable<Seams['permits']>,
109
+ known?: { readonly entities: ReadonlyMap<string, ReadonlySet<string>>; readonly keys: ReadonlyMap<string, string> },
110
+ ): { readonly seams: Seams; readonly sink: Sink; readonly kept: () => unknown; readonly calls: () => unknown } {
111
+ const made = inMemorySeams({
112
+ secrets: stood,
113
+ ...(scenario.views === undefined ? {} : { views: scenario.views }),
114
+ ...(known === undefined ? {} : { entities: known.entities, keys: known.keys }),
115
+ })
116
+
117
+ const found = made.seams as unknown as Record<string, unknown>
118
+
119
+ found['permits'] = gate ?? {
120
+ check() {
121
+ return Promise.resolve({ allow: true, because: 'this app derives no gate, so nothing here can refuse' })
122
+ },
123
+ }
124
+ found['clock'] = { now: () => WHEN }
125
+
126
+ let drawn = 0
127
+ found['random'] = {
128
+ next: () => {
129
+ drawn += 1
130
+ return drawn / 100
131
+ },
132
+ uuid: () => {
133
+ drawn += 1
134
+ return `00000000-0000-4000-8000-${String(drawn).padStart(12, '0')}`
135
+ },
136
+ }
137
+
138
+ const recorder = recordingHttp(scenario.answers ?? {})
139
+ found['http'] = recorder
140
+
141
+ return { seams: made.seams, sink: made.sink, kept: () => made.store, calls: () => recorder.calls }
142
+ }
143
+
144
+ function entered(sink: Sink): boolean {
145
+ return sink.logs.some((one) => {
146
+ const data = one.data as Record<string, unknown> | undefined
147
+ return data !== undefined && 'observability' in data
148
+ })
149
+ }
150
+
151
+ function denied(result: unknown): boolean {
152
+ if (typeof result !== 'object' || result === null) return false
153
+ const held = result as { readonly ok?: unknown; readonly code?: unknown }
154
+ return held.ok === false && held.code === 'denied'
155
+ }
156
+
157
+ export async function runScenario(
158
+ one: Operation<unknown>,
159
+ scenario: Scenario,
160
+ dispatch: (
161
+ operation: Operation<unknown>,
162
+ input: unknown,
163
+ actor: Actor,
164
+ seams: Seams,
165
+ ) => Promise<Result<unknown>>,
166
+ stood: Readonly<Record<string, string>> = {},
167
+ gate?: NonNullable<Seams['permits']>,
168
+ known?: { readonly entities: ReadonlyMap<string, ReadonlySet<string>>; readonly keys: ReadonlyMap<string, string> },
169
+ ): Promise<Trial> {
170
+
171
+ const made = seamsFor(one, scenario, stood, gate, known)
172
+
173
+ const actor = asking(scenario)
174
+
175
+ for (const seeded of scenario.rows ?? []) {
176
+ const held = made.seams.store
177
+ if (held === undefined) throw new Error(String(seeded.entity) + " cannot be seeded: these seams keep no rows")
178
+ await held.insert(actor, seeded.entity, seeded.row)
179
+ }
180
+
181
+ let result: unknown
182
+ try {
183
+ result = await dispatch(one, scenario.input, actor, made.seams)
184
+ } catch (thrown) {
185
+ result = { threw: thrown instanceof Error ? thrown.message : String(thrown) }
186
+ }
187
+
188
+ return {
189
+ operation: one.id,
190
+ scenario: scenario.id,
191
+ refused: denied(result),
192
+ measured: entered(made.sink),
193
+ result,
194
+ kept: made.kept(),
195
+ observed: made.sink,
196
+ called: made.calls(),
197
+ }
198
+ }
199
+
200
+ export async function scenariosFor(
201
+ one: Operation<unknown>,
202
+ takes: string,
203
+ granted: readonly string[] = [],
204
+ ): Promise<readonly Scenario[] | string> {
205
+ const from = resolve(ROOT, takes)
206
+ if (existsSync(from)) {
207
+ const made = (await import(pathToFileURL(from).href)) as { readonly SCENARIOS?: readonly Scenario[] }
208
+ const found = made.SCENARIOS
209
+ if (found !== undefined) {
210
+ if (found.length === 0) {
211
+ return `${takes} states an empty \`SCENARIOS\`, so nothing would be measured — remove it, or state one`
212
+ }
213
+ const named = new Set(found.map((each) => each.id))
214
+ if (named.size !== found.length) {
215
+ return `${takes} states two scenarios with the same id, and a trial is named after the one it ran`
216
+ }
217
+ return [...found].sort((a, b) => compare(a.id, b.id))
218
+ }
219
+ }
220
+
221
+ if (granted.length > 0) {
222
+ return (
223
+ `\`${one.id}\` is granted to ${granted.join(', ')}, and no scenario says who runs it. A caller is not ` +
224
+ `something to derive — a trial run as somebody the grant must allow proves nothing about access. State one ` +
225
+ `with \`thread run trial ${one.id}\`, which asks for the roles it arrives wearing, and export \`SCENARIOS\` ` +
226
+ `from ${takes}`
227
+ )
228
+ }
229
+
230
+ const empty = one.input.safeParse({})
231
+ if (empty.success) {
232
+ return [{ id: 'empty', describes: `runs ${one.id} with no input, as whoever arrives having said nothing`, input: {} }]
233
+ }
234
+
235
+ return (
236
+ `\`${one.id}\` takes an input that no empty object satisfies, and states no scenario to try it ` +
237
+ `with. Export \`SCENARIOS\` from ${takes} — until then this operation is not proven`
238
+ )
239
+ }
240
+
241
+ export function compared(was: readonly Trial[], now: readonly Trial[]): Run {
242
+ const before = new Map(was.map((one) => [`${one.operation}/${one.scenario}`, one]))
243
+ const outcomes: Outcome[] = []
244
+
245
+ for (const one of now) {
246
+ const id = `${one.operation}/${one.scenario}`
247
+ const failures: string[] = []
248
+ const older = before.get(id)
249
+
250
+ if (!one.refused && !one.measured) {
251
+ failures.push(
252
+ 'the run never entered the operation and nothing denied it, so nothing about the operation was ' +
253
+ `measured and no refusal was proven either. What it answered was ${JSON.stringify(one.result)}`,
254
+ )
255
+ }
256
+
257
+ if (one.refused && one.measured) {
258
+ failures.push(
259
+ 'this app\'s own gate denied the caller and the run REACHED THE OPERATION anyway — dispatch let a ' +
260
+ 'denied request through to the compiled function, which is the gate not holding rather than a trial drifting',
261
+ )
262
+ }
263
+
264
+ if (older === undefined) {
265
+ failures.push('no trial holds this scenario. Run `thread trials` and commit the result')
266
+ } else {
267
+ for (const part of ['result', 'kept', 'observed'] as const) {
268
+ const then = printed(older[part])
269
+ const said = printed(one[part])
270
+ if (then === said) continue
271
+ failures.push(
272
+ `${part} is not what was recorded — the operation's behaviour has changed.\n was: ${then.trim().slice(0, 400)}\n now: ${said.trim().slice(0, 400)}`,
273
+ )
274
+ }
275
+ }
276
+
277
+ outcomes.push({
278
+ id,
279
+ operation: one.operation,
280
+ passed: failures.length === 0,
281
+ refused: one.refused,
282
+ measured: one.measured,
283
+ failures,
284
+ })
285
+ }
286
+
287
+ for (const one of was) {
288
+ const id = `${one.operation}/${one.scenario}`
289
+ if (now.some((each) => `${each.operation}/${each.scenario}` === id)) continue
290
+ outcomes.push({
291
+ id,
292
+ operation: one.operation,
293
+ passed: false,
294
+ refused: one.refused,
295
+ measured: false,
296
+ failures: ['this scenario is recorded and no longer runs — an operation or a scenario was removed without the trials being regenerated'],
297
+ })
298
+ }
299
+
300
+ return {
301
+ outcomes,
302
+ passed: outcomes.filter((one) => one.passed).length,
303
+ failed: outcomes.filter((one) => !one.passed).length,
304
+ measured: outcomes.filter((one) => one.measured).length,
305
+ }
306
+ }
307
+
308
+ export function unproven(run: Run): readonly string[] {
309
+ const each = new Map<string, Outcome[]>()
310
+ for (const one of run.outcomes) {
311
+ const held = each.get(one.operation)
312
+ if (held === undefined) each.set(one.operation, [one])
313
+ else held.push(one)
314
+ }
315
+
316
+ const said: string[] = []
317
+ for (const [operation, outcomes] of [...each].sort((one, two) => compare(one[0], two[0]))) {
318
+ if (outcomes.some((one) => one.measured)) continue
319
+
320
+ const denied = outcomes.filter((one) => one.refused).length
321
+ const neither = outcomes.length - denied
322
+ const how = [
323
+ denied === 0
324
+ ? ''
325
+ : `${String(denied)} were denied by this app's own gate, which proves the gate turns somebody away and nothing whatever about the operation`,
326
+ neither === 0 ? '' : `${String(neither)} neither reached it nor were denied`,
327
+ ].filter((one) => one !== '')
328
+
329
+ said.push(
330
+ `\`${operation}\` is UNPROVEN — ${String(outcomes.length)} scenario(s) ran and not one of them reached it: ${how.join(', and ')}. ` +
331
+ 'Grant a caller the trial can run as, or give it a scenario the gate admits',
332
+ )
333
+ }
334
+
335
+ return said
336
+ }
337
+
338
+ async function standing(): Promise<Readonly<Record<string, string>>> {
339
+ const at = resolve(ROOT, '_threads/secrets.ts')
340
+ if (!existsSync(at)) return {}
341
+ let held: readonly { readonly id: string; readonly sends?: unknown }[]
342
+ try {
343
+ const taken = (await import(pathToFileURL(at).href)) as {
344
+ readonly SECRETS?: readonly { readonly id: string; readonly sends?: unknown }[]
345
+ }
346
+ held = taken.SECRETS ?? []
347
+ } catch {
348
+ return {}
349
+ }
350
+ const found: Record<string, string> = {}
351
+ for (const one of held) {
352
+ found[one.id] = one.sends === undefined ? `https://${one.id.replace(/_/g, '-')}.invalid` : `${one.id}-stood-in-for`
353
+ }
354
+ return found
355
+ }
356
+
357
+ export async function record(): Promise<{ readonly trials: readonly Trial[]; readonly refusals: readonly string[] }> {
358
+ const pass = descend()
359
+ const known = types(pass)
360
+ const refusals: string[] = []
361
+ const trials: Trial[] = []
362
+
363
+ const declared = [...operations(pass, known)].filter(([, one]) => one.body !== undefined)
364
+ if (declared.length === 0) return { trials: [], refusals: [] }
365
+
366
+ const stood = await standing()
367
+
368
+ const at = resolve(ROOT, DISPATCH_AT)
369
+ if (!existsSync(at)) {
370
+ return {
371
+ trials: [],
372
+ refusals: [
373
+ `this app has no ${DISPATCH_AT}. Run \`thread compile\` — an operation runs through dispatch and there is nothing here to run it with`,
374
+ ],
375
+ }
376
+ }
377
+ const made = (await import(pathToFileURL(at).href)) as {
378
+ readonly dispatch?: (
379
+ operation: Operation<unknown>,
380
+ input: unknown,
381
+ actor: Actor,
382
+ seams: Seams,
383
+ ) => Promise<Result<unknown>>
384
+ }
385
+ const dispatch = made.dispatch
386
+ if (dispatch === undefined) {
387
+ return { trials: [], refusals: [`${DISPATCH_AT} exports no \`dispatch\`, so nothing here can run an operation`] }
388
+ }
389
+
390
+ const kept = resolve(ROOT, ENTITIES_AT)
391
+ const entities = new Map<string, ReadonlySet<string>>()
392
+ const keys = new Map<string, string>()
393
+ if (existsSync(kept)) {
394
+ const sheet = (await import(pathToFileURL(kept).href)) as {
395
+ readonly ENTITIES?: readonly {
396
+ readonly id: string
397
+ readonly holds?: readonly { readonly id: string }[]
398
+ readonly links?: readonly { readonly id: string }[]
399
+ readonly must?: readonly { readonly unique?: readonly string[] }[]
400
+ }[]
401
+ }
402
+ for (const one of sheet.ENTITIES ?? []) {
403
+ entities.set(
404
+ one.id,
405
+ new Set([...(one.holds ?? []).map((each) => each.id), ...(one.links ?? []).map((each) => `${each.id}_id`)]),
406
+ )
407
+ const named = (one.must ?? []).flatMap((each) => (each.unique?.length === 1 ? [each.unique[0] as string] : []))
408
+ if (named.length === 1) keys.set(one.id, named[0] as string)
409
+ }
410
+ }
411
+
412
+ const permitted = resolve(ROOT, ACCESS_AT)
413
+ const may = new Map<string, string[]>()
414
+ if (existsSync(permitted)) {
415
+ const sheet = (await import(pathToFileURL(permitted).href)) as {
416
+ readonly GRANTS?: readonly {
417
+ readonly may?: readonly string[]
418
+ readonly to?: { readonly roles?: readonly string[] }
419
+ }[]
420
+ }
421
+ for (const one of sheet.GRANTS ?? []) {
422
+ for (const id of one.may ?? []) {
423
+ may.set(id, [...new Set([...(may.get(id) ?? []), ...(one.to?.roles ?? [])])])
424
+ }
425
+ }
426
+ }
427
+
428
+ const compiled = resolve(ROOT, PERMITS_AT)
429
+ const permits = existsSync(compiled)
430
+ ? ((await import(pathToFileURL(compiled).href)) as {
431
+ readonly permits?: (actor: Actor, action: string, resource: Row | null) => { allow: boolean; because?: string }
432
+ }).permits
433
+ : undefined
434
+ if (existsSync(compiled) && permits === undefined) {
435
+ return {
436
+ trials: [],
437
+ refusals: [`${PERMITS_AT} exports no \`permits\`, so a trial cannot be answered by the gate this app derives`],
438
+ }
439
+ }
440
+ const gate: NonNullable<Seams['permits']> | undefined =
441
+ permits === undefined
442
+ ? undefined
443
+ : { check: (actor, action, resource) => Promise.resolve(permits(actor, action, resource)) }
444
+
445
+ for (const [id, one] of [...declared].sort((a, b) => compare(a[0], b[0]))) {
446
+ if (one.body === undefined) continue
447
+ const from = resolve(ROOT, one.body)
448
+ if (!existsSync(from)) {
449
+ refusals.push(`\`${id}\` derives ${one.body} and that file is not there. Run \`thread compile\``)
450
+ continue
451
+ }
452
+
453
+ const held = (await import(pathToFileURL(from).href)) as { readonly operation?: Operation<unknown> }
454
+ const operation = held.operation
455
+ if (operation === undefined) {
456
+ refusals.push(`${one.body} exports no \`operation\`, so \`${id}\` cannot be run`)
457
+ continue
458
+ }
459
+
460
+ const takes = one.takes[0]?.at
461
+ if (takes === undefined) {
462
+ refusals.push(`\`${id}\` names nothing it takes, so nothing states its input or what to try it with`)
463
+ continue
464
+ }
465
+
466
+ const scenarios = await scenariosFor(operation, takes, may.get(id) ?? [])
467
+ if (typeof scenarios === 'string') {
468
+ refusals.push(scenarios)
469
+ continue
470
+ }
471
+
472
+ for (const scenario of scenarios) {
473
+ trials.push(await runScenario(operation, scenario, dispatch, stood, gate, { entities, keys }))
474
+ }
475
+ }
476
+
477
+ return { trials, refusals }
478
+ }
479
+
480
+ function kept(): readonly Trial[] {
481
+ const at = resolve(ROOT, TRIALS_AT)
482
+ if (!existsSync(at)) return []
483
+ return JSON.parse(readFileSync(at, 'utf8')) as readonly Trial[]
484
+ }
485
+
486
+ if (invoked(import.meta.url)) {
487
+ const { trials, refusals } = await record()
488
+
489
+ for (const one of refusals) console.error(`trial: ${one}`)
490
+
491
+ if (argv.includes('--check')) {
492
+ const run = compared(kept(), trials)
493
+ for (const outcome of run.outcomes) {
494
+ if (outcome.passed) continue
495
+ for (const failure of outcome.failures) console.error(`trial: ${outcome.id} — ${failure}`)
496
+ }
497
+
498
+ if (run.outcomes.length === 0 && refusals.length === 0) {
499
+ console.log('trial: this app declares no operation, so there is nothing to prove.')
500
+ exit(0)
501
+ }
502
+
503
+ const short = unproven(run)
504
+ for (const one of short) console.error(`trial: ${one}`)
505
+
506
+ console.log(
507
+ `trial: ${String(run.passed)} of ${String(run.outcomes.length)} passed, ${String(run.measured)} measured, ` +
508
+ `${String(short.length)} operation(s) unproven.`,
509
+ )
510
+ exit(run.failed === 0 && refusals.length === 0 && short.length === 0 ? 0 : 1)
511
+ }
512
+
513
+ if (refusals.length > 0) {
514
+ console.error(
515
+ `trial: ${String(refusals.length)} refusal(s) above, so ${TRIALS_AT} is left as it was — ` +
516
+ 'a run that was refused has nothing to record, and recording it would erase what does',
517
+ )
518
+ exit(1)
519
+ }
520
+
521
+ const at = resolve(ROOT, TRIALS_AT)
522
+ mkdirSync(dirname(at), { recursive: true })
523
+ writeFileSync(at, printed(trials))
524
+ const measured = trials.filter((one) => one.measured).length
525
+ stdout.write(
526
+ `trial: wrote ${TRIALS_AT} — ${String(trials.length)} trial(s) from ${String(measured)} run(s) that reached an operation.\n`,
527
+ )
528
+ exit(0)
529
+ }
@@ -0,0 +1,164 @@
1
+ import { payloadBytes } from './metering.ts'
2
+ import { ValidationError } from './result.ts'
3
+ import type { Http, HttpRequest, HttpResponse } from './seams.ts'
4
+
5
+ export const HTTP_CALL_ACTION = 'http.call'
6
+
7
+ export class HttpNotConfiguredError extends Error {
8
+ constructor() {
9
+ super(
10
+ 'no outward seam is configured. `http.call` needs `seams.http`, which is the transport an app ' +
11
+ 'wires — which hosts it may reach is declared in its tree and is not this seam\'s business.',
12
+ )
13
+ this.name = 'HttpNotConfiguredError'
14
+ }
15
+ }
16
+
17
+ export class OutwardTransportError extends Error {
18
+ constructor(
19
+ readonly target: string,
20
+ cause: unknown,
21
+ ) {
22
+ super(`outward call to "${target}" never reached it: ${cause instanceof Error ? cause.message : String(cause)}`)
23
+ this.name = 'OutwardTransportError'
24
+ }
25
+ }
26
+
27
+ export function outwardUrl(target: string, address: string, path: string): URL {
28
+ let base: URL
29
+ try {
30
+ base = new URL(address)
31
+ } catch {
32
+ throw new ValidationError(`the address resolved for "${target}" is not an absolute URL: ${JSON.stringify(address)}`)
33
+ }
34
+ if (base.protocol !== 'https:') {
35
+ throw new ValidationError(`outward calls go over https only, and "${target}" resolved to ${base.protocol.replace(':', '')}`)
36
+ }
37
+ if (base.search !== '' || base.hash !== '') {
38
+ throw new ValidationError(`the address for "${target}" carries a query or a fragment — those belong to the call`)
39
+ }
40
+ if (!path.startsWith('/')) {
41
+ throw new ValidationError(`an outward path starts with "/", and "${path}" does not`)
42
+ }
43
+ if (path.startsWith('//')) {
44
+ throw new ValidationError(`the path "${path}" is protocol-relative, which points at another host`)
45
+ }
46
+ const url = new URL(`${base.origin}${base.pathname.replace(/\/$/, '')}${path}`)
47
+ if (url.origin !== base.origin) {
48
+ throw new ValidationError(`the path "${path}" resolves to ${url.origin}, which is not where "${target}" is`)
49
+ }
50
+ return url
51
+ }
52
+
53
+ function requestBytes(headers: Readonly<Record<string, string>>, body: Uint8Array | undefined): number {
54
+ return payloadBytes(headers) + (body === undefined ? 0 : body.byteLength)
55
+ }
56
+
57
+ function wireBody(body: Uint8Array): ArrayBuffer {
58
+ const copy = new ArrayBuffer(body.byteLength)
59
+ new Uint8Array(copy).set(body)
60
+ return copy
61
+ }
62
+
63
+ export interface HttpSeamOptions {
64
+ readonly transport?: typeof fetch
65
+
66
+ readonly timeoutMs?: number
67
+ }
68
+
69
+ const DEFAULT_TIMEOUT_MS = 10_000
70
+
71
+ export function httpSeam(options: HttpSeamOptions = {}): Http {
72
+ const send = options.transport ?? fetch
73
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
74
+
75
+ return {
76
+ async call(request: HttpRequest): Promise<HttpResponse> {
77
+ const url = outwardUrl(request.target, request.address, request.path)
78
+
79
+ const headers: Record<string, string> = { ...request.headers }
80
+ if (request.contentType !== undefined) headers['content-type'] = request.contentType
81
+ if (request.idempotencyKey !== undefined) headers['idempotency-key'] = request.idempotencyKey
82
+
83
+ const bytesSent = requestBytes(headers, request.body)
84
+ const startedAt = Date.now()
85
+
86
+ let response: Response
87
+ try {
88
+ response = await send(url.toString(), {
89
+ method: request.method,
90
+ headers,
91
+ ...(request.body === undefined ? {} : { body: wireBody(request.body) }),
92
+ signal: AbortSignal.timeout(timeoutMs),
93
+ })
94
+ } catch (error) {
95
+
96
+ throw new OutwardTransportError(request.target, error)
97
+ }
98
+
99
+ const body = new Uint8Array(await response.arrayBuffer())
100
+ const responseHeaders = Object.fromEntries(response.headers.entries())
101
+
102
+ return {
103
+ status: response.status,
104
+ headers: responseHeaders,
105
+ body,
106
+ bytesSent,
107
+ bytesReceived: payloadBytes(responseHeaders) + body.byteLength,
108
+ durationMs: Date.now() - startedAt,
109
+ }
110
+ },
111
+ }
112
+ }
113
+
114
+ export interface RecordedCall {
115
+ readonly target: string
116
+ readonly method: string
117
+ readonly path: string
118
+ readonly headers: Readonly<Record<string, string>>
119
+ readonly body: string
120
+ }
121
+
122
+ export interface ScriptedAnswer {
123
+ readonly status: number
124
+ readonly headers?: Readonly<Record<string, string>>
125
+ readonly body?: string
126
+ }
127
+
128
+ const REDACTED = '(sent)'
129
+
130
+ export function recordingHttp(
131
+ script: Readonly<Record<string, ScriptedAnswer>> = {},
132
+ ): Http & { readonly calls: readonly RecordedCall[] } {
133
+ const calls: RecordedCall[] = []
134
+ const decoder = new TextDecoder()
135
+
136
+ return {
137
+ calls,
138
+ async call(request) {
139
+ outwardUrl(request.target, request.address, request.path)
140
+
141
+ const body = request.body === undefined ? '' : decoder.decode(request.body)
142
+ calls.push({
143
+ target: request.target,
144
+ method: request.method,
145
+ path: request.path,
146
+ headers: Object.fromEntries(Object.keys(request.headers ?? {}).map((one) => [one, REDACTED])),
147
+ body,
148
+ })
149
+
150
+ const answer = script[`${request.method} ${request.path}`] ?? { status: 404, body: '{"message":"not scripted"}' }
151
+ const answered = new TextEncoder().encode(answer.body ?? '')
152
+ const headers = answer.headers ?? {}
153
+ return {
154
+ status: answer.status,
155
+ headers,
156
+ body: answered,
157
+ bytesSent: requestBytes({ ...request.headers }, request.body),
158
+ bytesReceived: payloadBytes(headers) + answered.byteLength,
159
+
160
+ durationMs: 0,
161
+ }
162
+ },
163
+ }
164
+ }