@superbee/core 0.2.0-pre.16 → 0.2.0-pre.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +622 -5
- package/dist/blob-journal.d.ts +187 -2
- package/dist/blob-journal.d.ts.map +1 -1
- package/dist/blob-journal.js +377 -5
- package/dist/blob-journal.js.map +1 -1
- package/dist/body-replace-guards.d.ts +50 -0
- package/dist/body-replace-guards.d.ts.map +1 -0
- package/dist/body-replace-guards.js +158 -0
- package/dist/body-replace-guards.js.map +1 -0
- package/dist/builtin-recipes/content.d.ts +93 -0
- package/dist/builtin-recipes/content.d.ts.map +1 -0
- package/dist/builtin-recipes/content.js +288 -0
- package/dist/builtin-recipes/content.js.map +1 -0
- package/dist/builtin-recipes.d.ts +10 -0
- package/dist/builtin-recipes.d.ts.map +1 -0
- package/dist/builtin-recipes.js +97 -0
- package/dist/builtin-recipes.js.map +1 -0
- package/dist/bundle-health/index.d.ts +14 -0
- package/dist/bundle-health/index.d.ts.map +1 -0
- package/dist/bundle-health/index.js +12 -0
- package/dist/bundle-health/index.js.map +1 -0
- package/dist/bundle-health/kind-findings.d.ts +44 -0
- package/dist/bundle-health/kind-findings.d.ts.map +1 -0
- package/dist/bundle-health/kind-findings.js +68 -0
- package/dist/bundle-health/kind-findings.js.map +1 -0
- package/dist/bundle-health/legacy-page.d.ts +39 -0
- package/dist/bundle-health/legacy-page.d.ts.map +1 -0
- package/dist/bundle-health/legacy-page.js +50 -0
- package/dist/bundle-health/legacy-page.js.map +1 -0
- package/dist/bundle-health/report.d.ts +202 -0
- package/dist/bundle-health/report.d.ts.map +1 -0
- package/dist/bundle-health/report.js +438 -0
- package/dist/bundle-health/report.js.map +1 -0
- package/dist/code-point-order.d.ts +8 -0
- package/dist/code-point-order.d.ts.map +1 -0
- package/dist/code-point-order.js +20 -0
- package/dist/code-point-order.js.map +1 -0
- package/dist/connectors.d.ts +223 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +1143 -0
- package/dist/connectors.js.map +1 -0
- package/dist/document-field-actions.d.ts +18 -0
- package/dist/document-field-actions.d.ts.map +1 -1
- package/dist/document-field-actions.js +91 -29
- package/dist/document-field-actions.js.map +1 -1
- package/dist/document-mutation.d.ts +7 -9
- package/dist/document-mutation.d.ts.map +1 -1
- package/dist/document-mutation.js +16 -16
- package/dist/document-mutation.js.map +1 -1
- package/dist/file-journaled-backend.d.ts +31 -5
- package/dist/file-journaled-backend.d.ts.map +1 -1
- package/dist/file-journaled-backend.js +131 -118
- package/dist/file-journaled-backend.js.map +1 -1
- package/dist/hosted-client/blob-delivery.d.ts +6 -0
- package/dist/hosted-client/blob-delivery.d.ts.map +1 -1
- package/dist/hosted-client/blob-delivery.js +23 -1
- package/dist/hosted-client/blob-delivery.js.map +1 -1
- package/dist/hosted-client/blob-write.d.ts +386 -0
- package/dist/hosted-client/blob-write.d.ts.map +1 -0
- package/dist/hosted-client/blob-write.js +770 -0
- package/dist/hosted-client/blob-write.js.map +1 -0
- package/dist/hosted-client/client.d.ts +64 -0
- package/dist/hosted-client/client.d.ts.map +1 -1
- package/dist/hosted-client/client.js +31 -0
- package/dist/hosted-client/client.js.map +1 -1
- package/dist/hosted-client/directory.d.ts +46 -0
- package/dist/hosted-client/directory.d.ts.map +1 -0
- package/dist/hosted-client/directory.js +74 -0
- package/dist/hosted-client/directory.js.map +1 -0
- package/dist/hosted-client/errors.d.ts +10 -0
- package/dist/hosted-client/errors.d.ts.map +1 -1
- package/dist/hosted-client/errors.js +13 -0
- package/dist/hosted-client/errors.js.map +1 -1
- package/dist/hosted-client/inbox.d.ts +320 -0
- package/dist/hosted-client/inbox.d.ts.map +1 -0
- package/dist/hosted-client/inbox.js +379 -0
- package/dist/hosted-client/inbox.js.map +1 -0
- package/dist/hosted-client/index.d.ts +11 -4
- package/dist/hosted-client/index.d.ts.map +1 -1
- package/dist/hosted-client/index.js +11 -4
- package/dist/hosted-client/index.js.map +1 -1
- package/dist/hosted-client/journal.d.ts +103 -0
- package/dist/hosted-client/journal.d.ts.map +1 -0
- package/dist/hosted-client/journal.js +464 -0
- package/dist/hosted-client/journal.js.map +1 -0
- package/dist/hosted-client/node.d.ts +5 -1
- package/dist/hosted-client/node.d.ts.map +1 -1
- package/dist/hosted-client/node.js +6 -1
- package/dist/hosted-client/node.js.map +1 -1
- package/dist/hosted-client/write.d.ts +28 -2
- package/dist/hosted-client/write.d.ts.map +1 -1
- package/dist/hosted-client/write.js +33 -11
- package/dist/hosted-client/write.js.map +1 -1
- package/dist/hosted-transport/answer-rows.d.ts +3 -2
- package/dist/hosted-transport/answer-rows.d.ts.map +1 -1
- package/dist/hosted-transport/answer-rows.js +1 -1
- package/dist/hosted-transport/answer-rows.js.map +1 -1
- package/dist/hosted-transport/carrier.d.ts +10 -0
- package/dist/hosted-transport/carrier.d.ts.map +1 -1
- package/dist/hosted-transport/carrier.js +22 -2
- package/dist/hosted-transport/carrier.js.map +1 -1
- package/dist/hosted-transport/whole-document-transport.d.ts +1 -1
- package/dist/hosted-transport/whole-document-transport.d.ts.map +1 -1
- package/dist/hosted-transport/whole-document-transport.js +20 -12
- package/dist/hosted-transport/whole-document-transport.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/kind-authoring.d.ts +170 -0
- package/dist/kind-authoring.d.ts.map +1 -0
- package/dist/kind-authoring.js +451 -0
- package/dist/kind-authoring.js.map +1 -0
- package/dist/kind-conformance-error.d.ts +15 -0
- package/dist/kind-conformance-error.d.ts.map +1 -0
- package/dist/kind-conformance-error.js +20 -0
- package/dist/kind-conformance-error.js.map +1 -0
- package/dist/kinds-load.js +4 -4
- package/dist/kinds-load.js.map +1 -1
- package/dist/kinds.d.ts +2 -0
- package/dist/kinds.d.ts.map +1 -1
- package/dist/kinds.js +10 -2
- package/dist/kinds.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +25 -0
- package/dist/paths.js.map +1 -1
- package/dist/query-filter.d.ts +9 -0
- package/dist/query-filter.d.ts.map +1 -1
- package/dist/query-filter.js +19 -3
- package/dist/query-filter.js.map +1 -1
- package/dist/recipe-lifecycle/stamp.d.ts +11 -0
- package/dist/recipe-lifecycle/stamp.d.ts.map +1 -1
- package/dist/recipe-lifecycle/stamp.js +6 -0
- package/dist/recipe-lifecycle/stamp.js.map +1 -1
- package/dist/record-overlay/apply.d.ts +43 -0
- package/dist/record-overlay/apply.d.ts.map +1 -0
- package/dist/record-overlay/apply.js +527 -0
- package/dist/record-overlay/apply.js.map +1 -0
- package/dist/record-overlay/canonical.d.ts +22 -0
- package/dist/record-overlay/canonical.d.ts.map +1 -0
- package/dist/record-overlay/canonical.js +47 -0
- package/dist/record-overlay/canonical.js.map +1 -0
- package/dist/record-overlay/digest.d.ts +4 -0
- package/dist/record-overlay/digest.d.ts.map +1 -0
- package/dist/record-overlay/digest.js +11 -0
- package/dist/record-overlay/digest.js.map +1 -0
- package/dist/record-overlay/errors.d.ts +44 -0
- package/dist/record-overlay/errors.d.ts.map +1 -0
- package/dist/record-overlay/errors.js +55 -0
- package/dist/record-overlay/errors.js.map +1 -0
- package/dist/record-overlay/findings.d.ts +5 -0
- package/dist/record-overlay/findings.d.ts.map +1 -0
- package/dist/record-overlay/findings.js +83 -0
- package/dist/record-overlay/findings.js.map +1 -0
- package/dist/record-overlay/index.d.ts +23 -0
- package/dist/record-overlay/index.d.ts.map +1 -0
- package/dist/record-overlay/index.js +21 -0
- package/dist/record-overlay/index.js.map +1 -0
- package/dist/record-overlay/plan.d.ts +12 -0
- package/dist/record-overlay/plan.d.ts.map +1 -0
- package/dist/record-overlay/plan.js +285 -0
- package/dist/record-overlay/plan.js.map +1 -0
- package/dist/record-overlay/policy.d.ts +69 -0
- package/dist/record-overlay/policy.d.ts.map +1 -0
- package/dist/record-overlay/policy.js +228 -0
- package/dist/record-overlay/policy.js.map +1 -0
- package/dist/record-overlay/redact.d.ts +3 -0
- package/dist/record-overlay/redact.d.ts.map +1 -0
- package/dist/record-overlay/redact.js +27 -0
- package/dist/record-overlay/redact.js.map +1 -0
- package/dist/record-overlay/signal-kind.d.ts +30 -0
- package/dist/record-overlay/signal-kind.d.ts.map +1 -0
- package/dist/record-overlay/signal-kind.js +38 -0
- package/dist/record-overlay/signal-kind.js.map +1 -0
- package/dist/record-overlay/signal-text.d.ts +45 -0
- package/dist/record-overlay/signal-text.d.ts.map +1 -0
- package/dist/record-overlay/signal-text.js +116 -0
- package/dist/record-overlay/signal-text.js.map +1 -0
- package/dist/record-overlay/source-index.d.ts +19 -0
- package/dist/record-overlay/source-index.d.ts.map +1 -0
- package/dist/record-overlay/source-index.js +102 -0
- package/dist/record-overlay/source-index.js.map +1 -0
- package/dist/record-overlay/types.d.ts +291 -0
- package/dist/record-overlay/types.d.ts.map +1 -0
- package/dist/record-overlay/types.js +2 -0
- package/dist/record-overlay/types.js.map +1 -0
- package/dist/remote-operations.js +5 -2
- package/dist/remote-operations.js.map +1 -1
- package/dist/search/contract.d.ts +151 -0
- package/dist/search/contract.d.ts.map +1 -0
- package/dist/search/contract.js +78 -0
- package/dist/search/contract.js.map +1 -0
- package/dist/search/errors.d.ts +30 -0
- package/dist/search/errors.d.ts.map +1 -0
- package/dist/search/errors.js +50 -0
- package/dist/search/errors.js.map +1 -0
- package/dist/search/identity.d.ts +12 -0
- package/dist/search/identity.d.ts.map +1 -0
- package/dist/search/identity.js +90 -0
- package/dist/search/identity.js.map +1 -0
- package/dist/search/index.d.ts +15 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +14 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/memory-source.d.ts +25 -0
- package/dist/search/memory-source.d.ts.map +1 -0
- package/dist/search/memory-source.js +230 -0
- package/dist/search/memory-source.js.map +1 -0
- package/dist/search/rank.d.ts +34 -0
- package/dist/search/rank.d.ts.map +1 -0
- package/dist/search/rank.js +129 -0
- package/dist/search/rank.js.map +1 -0
- package/dist/search/row-text.d.ts +38 -0
- package/dist/search/row-text.d.ts.map +1 -0
- package/dist/search/row-text.js +88 -0
- package/dist/search/row-text.js.map +1 -0
- package/dist/search/search.d.ts +28 -0
- package/dist/search/search.d.ts.map +1 -0
- package/dist/search/search.js +240 -0
- package/dist/search/search.js.map +1 -0
- package/dist/search/source.d.ts +67 -0
- package/dist/search/source.d.ts.map +1 -0
- package/dist/search/source.js +2 -0
- package/dist/search/source.js.map +1 -0
- package/dist/search/text.d.ts +47 -0
- package/dist/search/text.d.ts.map +1 -0
- package/dist/search/text.js +148 -0
- package/dist/search/text.js.map +1 -0
- package/dist/source-engine/guard.d.ts +82 -0
- package/dist/source-engine/guard.d.ts.map +1 -0
- package/dist/source-engine/guard.js +396 -0
- package/dist/source-engine/guard.js.map +1 -0
- package/dist/source-engine/index.d.ts +24 -0
- package/dist/source-engine/index.d.ts.map +1 -0
- package/dist/source-engine/index.js +20 -0
- package/dist/source-engine/index.js.map +1 -0
- package/dist/source-engine/machine-state.d.ts +74 -0
- package/dist/source-engine/machine-state.d.ts.map +1 -0
- package/dist/source-engine/machine-state.js +48 -0
- package/dist/source-engine/machine-state.js.map +1 -0
- package/dist/source-engine/observe.d.ts +18 -0
- package/dist/source-engine/observe.d.ts.map +1 -0
- package/dist/source-engine/observe.js +598 -0
- package/dist/source-engine/observe.js.map +1 -0
- package/dist/source-engine/robots.d.ts +53 -0
- package/dist/source-engine/robots.d.ts.map +1 -0
- package/dist/source-engine/robots.js +158 -0
- package/dist/source-engine/robots.js.map +1 -0
- package/dist/source-engine/types.d.ts +285 -0
- package/dist/source-engine/types.d.ts.map +1 -0
- package/dist/source-engine/types.js +2 -0
- package/dist/source-engine/types.js.map +1 -0
- package/dist/source-harness/conformance.d.ts +16 -0
- package/dist/source-harness/conformance.d.ts.map +1 -0
- package/dist/source-harness/conformance.js +265 -0
- package/dist/source-harness/conformance.js.map +1 -0
- package/dist/source-harness/fixture.d.ts +95 -0
- package/dist/source-harness/fixture.d.ts.map +1 -0
- package/dist/source-harness/fixture.js +261 -0
- package/dist/source-harness/fixture.js.map +1 -0
- package/dist/source-harness/index.d.ts +22 -0
- package/dist/source-harness/index.d.ts.map +1 -0
- package/dist/source-harness/index.js +19 -0
- package/dist/source-harness/index.js.map +1 -0
- package/dist/source-harness/peer.d.ts +74 -0
- package/dist/source-harness/peer.d.ts.map +1 -0
- package/dist/source-harness/peer.js +106 -0
- package/dist/source-harness/peer.js.map +1 -0
- package/dist/storage.d.ts +2 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1 -1
- package/dist/storage.js.map +1 -1
- package/dist/uncertain-write.d.ts +39 -6
- package/dist/uncertain-write.d.ts.map +1 -1
- package/dist/uncertain-write.js +25 -3
- package/dist/uncertain-write.js.map +1 -1
- package/package.json +33 -1
package/README.md
CHANGED
|
@@ -173,6 +173,303 @@ target this bundle's edition), `RecipeAssetConflict` (`recipe`, `key`), `RecipeE
|
|
|
173
173
|
`RecipeEvolutionInterrupted` (`reason`, `completed`, `pending`, `cause`) and
|
|
174
174
|
`RecipeEvolutionPostcondition` (`reason`, `completed`).
|
|
175
175
|
|
|
176
|
+
## Record overlay
|
|
177
|
+
|
|
178
|
+
`@superbee/core/record-overlay` is the write half of binding documents to external records. A
|
|
179
|
+
Source Feed policy names the fields a feed owns; the caller fetches and diffs its records and hands
|
|
180
|
+
the changes to the planner. It uses no Node globals or builtins, so it runs in Node, a browser or a
|
|
181
|
+
Worker over any storage backend.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { MemoryBackend } from "@superbee/core/memory-backend";
|
|
185
|
+
import { buildKindRegistry } from "@superbee/core/kinds";
|
|
186
|
+
import { applyRecordOverlay, buildSourceIndex, planRecordOverlay, readOverlayPolicy } from "@superbee/core/record-overlay";
|
|
187
|
+
|
|
188
|
+
const bundle = { root: "memory://guide", backend: new MemoryBackend() };
|
|
189
|
+
const registry = buildKindRegistry(conventionDocs);
|
|
190
|
+
const guide = { bundle, registry, okfVersion: "0.2" as const };
|
|
191
|
+
const policy = readOverlayPolicy(sourceFeedDoc, { targetKind: registry.kinds.get("Event") });
|
|
192
|
+
const overlays = await buildSourceIndex(bundle, { type: policy.target_kind });
|
|
193
|
+
const plan = await planRecordOverlay({ content: guide, ops: guide, policy, changes, observedAt, overlays });
|
|
194
|
+
const receipt = await applyRecordOverlay(plan, { content: guide, ops: guide, policy, actor: "process:calendar-sync", overlays });
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
For each owned field the plan compares the last accepted feed value, the new feed value and the
|
|
198
|
+
document's value: a document that already says the new value is left alone, one that still says
|
|
199
|
+
the old value is written, and anything else is an `OVERLAY_DOC_CONFLICT` with a Signal, so an
|
|
200
|
+
editor's override is never overwritten. Each step carries the version it was planned against.
|
|
201
|
+
`applyRecordOverlay` writes every step through `mutateDocument` with a guard core builds itself and
|
|
202
|
+
runs on the fresh read: an owned-field write reaches documents of the policy's target Kind only,
|
|
203
|
+
may change only owned and derived fields, never the body, and needs the record's `sources`
|
|
204
|
+
identity; a Signal may be created only as a new, unclaimed document of the attention Kind under its
|
|
205
|
+
path, and updated only in a fixed set of fields plus one appended observation line. Signal writes
|
|
206
|
+
are bound to the provider of the policy the caller passes, never to a field of the plan, and an id
|
|
207
|
+
held by another provider's Signal is refused. A Signal's text must be the planner's rendering of
|
|
208
|
+
its own fields and the run (its observation line, links to the plan's feed and to documents, the
|
|
209
|
+
adoption hint): every plan the planner makes passes, and other text is a held `INVALID_INPUT` row,
|
|
210
|
+
so a mismatch is visible and planned again rather than dropped. Nothing of the
|
|
211
|
+
target Kind is created and nothing is deleted. A feed never owns its target's work state: the
|
|
212
|
+
progress storage fields and the target Kind's claim fields are refused like `status`. The
|
|
213
|
+
attention Kind (default `Signal`) must declare a claim owner and state field and the fields
|
|
214
|
+
`change_kind`, `observed_at`, `provider`, `external_id` and `diff_summary`.
|
|
215
|
+
|
|
216
|
+
Site rules are hooks with safe defaults: `textGate` (identity), `interval` (none, so a disappeared
|
|
217
|
+
record is always raised), `correlate`, `sourceProperties` (an upsert of the record's `sources`
|
|
218
|
+
entry in the same write, OKF v0.2 only), `assertCandidate` (site candidate checks after the core
|
|
219
|
+
guards) and `deriveFields` (fields such as `updated_at` written in the same version). Refusals are
|
|
220
|
+
receipt rows with a `REFUSAL_CODES` code; `RETRYABLE` and `HOLD_STORE` say which leave a record to be
|
|
221
|
+
planned again. A value core's write policy refuses (an OKF v0.2 field type, for example) is a held
|
|
222
|
+
`INVALID_INPUT` row, so one bad record never stops the rest of a run. A malformed plan (an unknown
|
|
223
|
+
step, a non-canonical target, written values that are not JSON data with finite numbers, prototype
|
|
224
|
+
keys, non-scalar or identity `sources` properties), actor or bundle edition throws `OverlayRefusal`
|
|
225
|
+
before any write, and so does a plan with owned-field steps whose policy names no declared target
|
|
226
|
+
Kind or whose owned or derived list names a field a feed can never own.
|
|
227
|
+
|
|
228
|
+
`SourceIndex` holds one head read for a whole run. It is bounded to that run: an edited or deleted
|
|
229
|
+
overlay is still caught at write time, but a second document given an identity during the run is
|
|
230
|
+
only refused as ambiguous on the next run.
|
|
231
|
+
|
|
232
|
+
## Connectors
|
|
233
|
+
|
|
234
|
+
`@superbee/core/connectors` parses Connector declarations: `type: Connector` documents under
|
|
235
|
+
`connectors/` that say what an integration is, independent of any one target Kind. A declaration
|
|
236
|
+
holds no authority and nothing in the subpath fetches, executes or receives. It uses no Node globals
|
|
237
|
+
or builtins.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { bindSourceFeed, changeBetween, connectorPinDigest, findingsFor, mapRecord, parseConnectorDoc } from "@superbee/core/connectors";
|
|
241
|
+
|
|
242
|
+
const parsed = parseConnectorDoc(connectorDoc, { kinds: registry });
|
|
243
|
+
if (!parsed.ok) throw new Error(parsed.errors.map((e) => `${e.code} ${e.path}`).join("; "));
|
|
244
|
+
const pin = connectorPinDigest(parsed.connector);
|
|
245
|
+
const bound = bindSourceFeed(parsed.connector, sourceFeedDoc, registry, { okfVersion: "0.2" });
|
|
246
|
+
const mapped = mapRecord(bound.source, normalizedRecord, bound.kind);
|
|
247
|
+
if (mapped.kind === "mapped") {
|
|
248
|
+
const change = changeBetween(lastAcceptedAfter, mapped, { external_id: mapped.external_id, change_kind: "changed" });
|
|
249
|
+
}
|
|
250
|
+
const findings = findingsFor(mapped.diagnostics, { source: bound.source.name });
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`auth.kind` is `none`, `token`, `github-app`, `oauth2` or `signed-webhook`. A credential's hosts
|
|
254
|
+
come only from core: `token` and `oauth2` name a row of `AUTH_PROVIDERS`, `github-app` is fixed,
|
|
255
|
+
and `credentialHosts` is the one reader. `domains` lists hosts for keyless fetches and may not
|
|
256
|
+
repeat a credential host. Each source names a catalog `profile`, a `target_kind`, mode `overlay`,
|
|
257
|
+
an identity template (`identity.resource`, an https URL whose placeholders are feed parameters, and
|
|
258
|
+
`identity.id` over record paths), `completeness`, and `fields`: target field to a dotted record
|
|
259
|
+
path, or to `{ from, values, unmapped }`. Mapping keys are the most a Source Feed may own; never-owned
|
|
260
|
+
fields, managed metadata, work state, the body and list-typed fields are refused. Actions and events
|
|
261
|
+
are declared shapes only. Every nested block is closed by `CONNECTOR_KEYS`; `parseConnectorDoc`
|
|
262
|
+
returns every refusal with a typed `code` and a JSON pointer `path`. A token shape or URL userinfo
|
|
263
|
+
anywhere in a frontmatter string, key or the body, or a credential key name outside the data-name
|
|
264
|
+
maps, refuses the document alone, naming a path and never the value; so does a YAML alias, which
|
|
265
|
+
keeps every reader bounded by the document's size. The secret rule is a tripwire; the pin, the
|
|
266
|
+
credential store and the fetcher are the boundaries.
|
|
267
|
+
|
|
268
|
+
`connectorPinDigest` covers every key `CONNECTOR_KEYS` accepts, with defaults filled and order-free
|
|
269
|
+
lists sorted, plus the credential hosts core resolves the auth kind to, so a host joining an
|
|
270
|
+
`AUTH_PROVIDERS` row changes the pin. It is blind to the title, description, version, body and
|
|
271
|
+
unknown top-level keys. `CONNECTOR_PIN_RULES` changes whenever a default or sort rule does.
|
|
272
|
+
|
|
273
|
+
`bindSourceFeed` binds a Source Feed (`connector`, `source`, `url`, `owned_fields`) to one source:
|
|
274
|
+
the `url` must equal the identity template rendered from parameters of `[A-Za-z0-9._~-]+` byte for
|
|
275
|
+
byte, `owned_fields` must be mapping keys, and the source's `target_kind`, `completeness` and
|
|
276
|
+
`profile` win. `okfVersion` is the content bundle's edition (absent means v0.1); a feed owning a
|
|
277
|
+
Kind-declared list field binds only on v0.2. Each refusal is an `OverlayRefusal` with
|
|
278
|
+
`POLICY_INVALID`, the feed and the key.
|
|
279
|
+
`mapRecord` never lets a mapping gap erase a stored value: an absent path is unobserved, an
|
|
280
|
+
unmapped native is kept (`unmapped: keep`, the default) or refuses the record while it still counts
|
|
281
|
+
as present (`refuse`), and only an empty value (`null` or blank text) clears an optional field; on a
|
|
282
|
+
required field or `title` it is kept with a finding. An id template separates its placeholders with
|
|
283
|
+
literal text, and a record whose value holds that text, is blank or makes an id past 2048
|
|
284
|
+
characters is refused as `IDENTITY_MISSING` or `IDENTITY_INVALID`, so two records never share one id. `changeBetween` takes the last accepted `after`, carries
|
|
285
|
+
unobserved fields and leaves them out of `changed_fields`. `findingsFor` groups diagnostics per
|
|
286
|
+
source, field, code and value (a list, mapping or empty value by its shape) for `planFindings`, so
|
|
287
|
+
one problem across many records raises one Signal.
|
|
288
|
+
|
|
289
|
+
`examples/recipes/source-feeds` is an example recipe with the `Source Feed` and `Signal` Kinds a
|
|
290
|
+
connector-backed feed uses.
|
|
291
|
+
|
|
292
|
+
## Source engine
|
|
293
|
+
|
|
294
|
+
`@superbee/core/source-engine` is the portable half of the integration source engine: the one
|
|
295
|
+
loop that observes a bound Source Feed for an author's test, a local run or a managed runner. It
|
|
296
|
+
uses no Node globals or builtins and never calls `fetch`; the caller supplies the network as a
|
|
297
|
+
`Transport`.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { MemoryMachineState, fetchIdentity, observeSource } from "@superbee/core/source-engine";
|
|
301
|
+
|
|
302
|
+
const receipt = await observeSource({
|
|
303
|
+
connector, bound, profile, spec, transport, state: new MemoryMachineState(),
|
|
304
|
+
identity: fetchIdentity("Product/1.0 (+https://example.org/about)")!,
|
|
305
|
+
mode: "incremental", signal: AbortSignal.timeout(60_000),
|
|
306
|
+
commit: async (page) => {
|
|
307
|
+
const plan = await planRecordOverlay({ content, ops, policy: bound.policy, changes: page.changes, observedAt: page.observed_at, records: page.records });
|
|
308
|
+
await applyRecordOverlay(plan, { content, ops, policy: bound.policy, actor: "process:calendar-sync" });
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
A `SourceProfile` (`id`, `version`, `kind`, `validateSpec`, `hostsFor`, `observe`) turns a bound
|
|
314
|
+
feed, a cursor and a transport into pages of normalized records: `{ records, nextCursor, more,
|
|
315
|
+
complete }`, all JSON data. It never opens a socket, never sees a credential and never writes. A
|
|
316
|
+
`Transport` answers `{ url, method, headers, body, maxBytes, credentialHost, signal }`; it never
|
|
317
|
+
follows a redirect and returns `{ ok: false, refused: "too_large" }` rather than a truncated body.
|
|
318
|
+
A credential-injecting transport adds its credential only when the engine's `credentialHost` is
|
|
319
|
+
true and its own exact match agrees: https, the default port, a host exactly one of the auth
|
|
320
|
+
kind's credential hosts.
|
|
321
|
+
|
|
322
|
+
The profile sees the engine's guarded transport, which checks every URL and every redirect hop:
|
|
323
|
+
https only, the lexical address rule (`refusedAddress`: no IP literal, local name, port or
|
|
324
|
+
userinfo), and a host within the profile's `hostsFor` and the connector's credential hosts plus
|
|
325
|
+
keyless `domains`. A `hostsFor` naming any other host refuses the run before a request. A redirect
|
|
326
|
+
never changes host when either side is a credential host. Keyless hosts are read through robots.txt
|
|
327
|
+
(RFC 9309, the declared identity's product token, the first 512 KiB) first, and a bot-mitigation
|
|
328
|
+
page from one is refused and never retried. Only `GET` is sent unless `rules.post` admits a POST
|
|
329
|
+
toward a credential host; a keyless host is GET only. A GET with a body, a POST redirect, and the
|
|
330
|
+
headers `authorization`, `cookie`, `proxy-authorization`, `host`, `user-agent` and the method
|
|
331
|
+
overrides are always refused; beyond `accept` a header needs `rules.header`, and the validators
|
|
332
|
+
`if-none-match` and `if-modified-since` go out in incremental mode only. The budget counts every
|
|
333
|
+
request, robots.txt and hops included. Each request ends after `requestTimeoutMs` (default 20
|
|
334
|
+
seconds). The deadline is the caller's `signal`; without one the run ends after `deadlineMs`
|
|
335
|
+
(default 5 minutes). The deadline bounds the profile's page too: a page still pending is discarded
|
|
336
|
+
and the run ends `deferred`. A commit or state write in progress is never abandoned, so a hung one
|
|
337
|
+
holds the run. Statuses map to outcomes: 401 and 403 `credential_refused` (no retry), 404 and
|
|
338
|
+
410 `not_found`, 429 `deferred` with `retry_after`, others `provider_unavailable` (503 included,
|
|
339
|
+
with a retry); `statuses` moves a status between the sets. Any refusal or error status stops the
|
|
340
|
+
run, even when the profile catches the error, and the guarded transport is closed when the run
|
|
341
|
+
ends.
|
|
342
|
+
|
|
343
|
+
A profile runs in the engine's realm. The guard bounds what a declaration and a provider's data can
|
|
344
|
+
reach; profile code itself is bounded by the repository's static egress check
|
|
345
|
+
(`scripts/source-egress-check.mjs`) and by review, until profiles run in an isolate. The check
|
|
346
|
+
refuses globals outside a short list, imports outside an allowlist or the scanned roots, writes to
|
|
347
|
+
anything reached from a global, the prototype, property-definition and descriptor names however
|
|
348
|
+
they are read, `this` outside a class body and CommonJS; a key computed at run time is beyond any
|
|
349
|
+
static check.
|
|
350
|
+
|
|
351
|
+
Each page is mapped with `mapRecord`, diffed against the stored records into `RecordChange` rows
|
|
352
|
+
and handed to `commit`; the cursor and the records are stored only after `commit` resolves. A page
|
|
353
|
+
that promises more with no cursor, or with a cursor the run already asked for, fails the run as
|
|
354
|
+
`cursor_stuck`. `commit` may decline to write changes: a `retry` change (a lost compare-and-swap, a
|
|
355
|
+
claimed Signal) stays pending in the machine state and is planned again at the start of every run,
|
|
356
|
+
before anything is fetched, until it is written, refused for good, or superseded by a newer
|
|
357
|
+
observation of the record (its listing, or its absence from a complete pass); a replay keeps the
|
|
358
|
+
record's missing count, and a record that disappeared is not replayed. A replay whose commit throws
|
|
359
|
+
stays pending with a finding while the run reads on, and is dropped with a finding after
|
|
360
|
+
`MAX_REPLAY_FAILURES` (5). A page that supersedes pending changes stores their removal before its
|
|
361
|
+
commit, so a crash after the commit never replays an older change. A `hold` change is not pending,
|
|
362
|
+
and its record is planned again when it is next observed. Either way the cursor moves on, so one
|
|
363
|
+
record never stops a feed. An incremental
|
|
364
|
+
run continues the stored cursor (`resumed` when the last run left its pass open); a complete run
|
|
365
|
+
lists from the start, sends no validator, and stops on a not-modified answer. A pass is a complete
|
|
366
|
+
observation only when it ran in complete mode to its last page within the run, the profile claimed
|
|
367
|
+
it complete and the source is declared `complete`; only then is a listed record's absence counted,
|
|
368
|
+
and a record is `disappeared` after the feed's `missing_threshold` (default 2) of them. A cursor
|
|
369
|
+
from another profile id or version is never handed back. The receipt names the outcome, every
|
|
370
|
+
request, the pass, the stored cursor, the held and pending counts, the mapping diagnostics and the
|
|
371
|
+
findings for `planFindings`. `MachineState` is the port for the stored state, written with a
|
|
372
|
+
compare-and-swap; `MemoryMachineState` keeps it in memory.
|
|
373
|
+
|
|
374
|
+
## Source harness
|
|
375
|
+
|
|
376
|
+
`@superbee/core/source-harness` is how a profile and a transport prove the engine's rules offline.
|
|
377
|
+
It returns rows and problem lists rather than asserting, so any test runner can use it, and it uses
|
|
378
|
+
no Node globals or builtins.
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import { peerTransport, runSourceFixture, runTransportConformance } from "@superbee/core/source-harness";
|
|
382
|
+
|
|
383
|
+
const { problems, missing } = await runSourceFixture(fixture, scenarios);
|
|
384
|
+
const rows = await runTransportConformance({ name: "mine", create: (peer, credential) => myTransport(peer, credential) });
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`runSourceScenario` replays a scenario's scripted provider answers (`exchanges`, each used once,
|
|
388
|
+
matched on method and URL; a `hang` answer aborts the run's deadline) through `observeSource`,
|
|
389
|
+
writing each committed page through record-overlay over a MemoryBackend bundle built from the
|
|
390
|
+
fixture's documents, with editor edits between runs. `checkScenario` holds the result to the rule
|
|
391
|
+
of its required scenario (`REQUIRED_SCENARIOS`: provider errors, an incomplete page, cursor resume,
|
|
392
|
+
a hostile cursor refused by the guard, disappearance only from complete observations, owned-field conflicts, unmapped
|
|
393
|
+
and empty values, a missing identity, a cross-host redirect and an undeclared host), and fails any
|
|
394
|
+
request to an undeclared host or one the fixture did not script; a name may add a variant
|
|
395
|
+
(`cursor.resume.page-number`). `missingScenarios` lists what a fixture lacks.
|
|
396
|
+
|
|
397
|
+
`runTransportConformance` is the suite every transport passes against a `ScriptedPeer`, the
|
|
398
|
+
network that records exactly what arrived: the credential reaches its host under its own header
|
|
399
|
+
name (a custom `accessToken` included) and no other host, lookalikes included (a longer or
|
|
400
|
+
shorter name, a trailing dot, userinfo, another port), nor its own host when the engine's
|
|
401
|
+
`credentialHost` is false; a redirect is returned unfollowed, a body
|
|
402
|
+
over the bound is refused while one at the bound is returned whole, and behind the engine a
|
|
403
|
+
redirect to another host or a private address reaches nothing. `peerTransport` is a memory
|
|
404
|
+
transport that passes it.
|
|
405
|
+
|
|
406
|
+
## Bundle health
|
|
407
|
+
|
|
408
|
+
`@superbee/core/bundle-health` computes the health report behind `superbee status` from a core
|
|
409
|
+
`Bundle` on any backend: malformed documents, Kind lint and conformance debt, registry warnings,
|
|
410
|
+
unresolved links, orphans, freshness and invalid timestamps, trust tiers, the two graph lints, the
|
|
411
|
+
View surface, cross-bundle links, the OKF v0.1 upgrade finding and legacy naming. It uses no Node
|
|
412
|
+
globals or builtins, so it runs in Node, a browser or a Worker. The caller presents the report;
|
|
413
|
+
the CLI adds row caps, help text and its home and sync blocks.
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { MemoryBackend } from "@superbee/core/memory-backend";
|
|
417
|
+
import { bundleHealth } from "@superbee/core/bundle-health";
|
|
418
|
+
|
|
419
|
+
const bundle = { root: "memory://guide", backend: new MemoryBackend() };
|
|
420
|
+
const report = await bundleHealth(bundle, { now: new Date() });
|
|
421
|
+
const gate = { malformed: report.malformed.length, kind_warnings: report.kindLint.length, unresolved_links: report.unresolvedLinks.length };
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
The report reads only: one Kind registry load, one lenient query, two prefix-scoped blob listings
|
|
425
|
+
and one root-index read, with every finding derived in memory. A document whose frontmatter does
|
|
426
|
+
not parse is a `malformed` row (the root index first), never a thrown error. Top-level keys are
|
|
427
|
+
camelCase; rows keep snake_case keys (`age_ms`, `bundle_id`, `incompatible_values`) and arrive
|
|
428
|
+
whole, in report order, for the caller to cap. Sections whose presence depends on the bundle are
|
|
429
|
+
null when absent: `conformanceDebt` without any declared Kind, `trust` outside a non-empty v0.2
|
|
430
|
+
bundle, `crossBundle` without `docs/dependencies` or any `okf:` link, `okfUpgrade` outside a v0.1
|
|
431
|
+
bundle with a workflow-status collision. `crossLinkTypeViolations` is always an array, because a
|
|
432
|
+
local edge under a qualified declaration needs no dependencies. The stored values in
|
|
433
|
+
`invalidStaleAfter`, `invalidTimestamps` and `invalidLegacyTimestamps` are made JSON-safe;
|
|
434
|
+
`missingExpectedLinks[].status` is the raw stored value.
|
|
435
|
+
|
|
436
|
+
Nothing outside the bundle is read unless the caller binds it. `bindings` is a map of bound copies
|
|
437
|
+
by alias, or a hook core calls exactly once, after every read and with its own parse of
|
|
438
|
+
`docs/dependencies` (a self alias already dropped as `DEP_SELF`, using `selfBundleId` when
|
|
439
|
+
`docs/bundle` declares no id). Whatever the hook throws propagates unchanged. Each binding's
|
|
440
|
+
verdict comes from core's resolver, and `dependencies[].bound` is the binding's `label`.
|
|
441
|
+
|
|
442
|
+
`bundleKindFindings(documents, okfVersion, { actor, producer })` answers a different question:
|
|
443
|
+
which Kind problems a strict host write would refuse, preparing each document as its first edit,
|
|
444
|
+
so a timestamp or actor the write supplies is no finding. The CLI's publish preview runs it; the
|
|
445
|
+
report's `kindLint` stays advisory. Both use core's one validator, `validateAgainstKind`.
|
|
446
|
+
|
|
447
|
+
## Search
|
|
448
|
+
|
|
449
|
+
`@superbee/core/search` is the one `documents.search.v1` engine: the request and result contract,
|
|
450
|
+
query normalization, ranking (`portal-webmcp-search/v1`), the any-term fallback, facets, cursors
|
|
451
|
+
and page budgets. A backend supplies what it searches through `SearchSource`: one `evidence()`
|
|
452
|
+
call per request and `text()` for the page's excerpts. `memorySearchSource` indexes a complete
|
|
453
|
+
corpus in memory. The subpath uses no Node globals or builtins.
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
import { memorySearchSource, searchDocuments } from "@superbee/core/search";
|
|
457
|
+
|
|
458
|
+
const source = memorySearchSource(documents, { key: headsDigest });
|
|
459
|
+
const result = await searchDocuments(source, { query: "lock keeper", limit: 10 }, { scope: bundleId });
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`key` identifies the corpus; a cursor is refused for another key, scope, query, match or filters
|
|
463
|
+
(`type`, `tag`, `prefix`). A request outside the contract throws `SearchInputError`; a corpus over a
|
|
464
|
+
bound throws `SearchLimitError`; a request whose terms hold more than `maxEvidenceInstances` capped
|
|
465
|
+
instances (a `searchDocuments` option, at most `SEARCH_LIMITS.maxEvidenceInstances`) throws
|
|
466
|
+
`SearchBroadQueryError`, a `SearchLimitError` whose message asks for fewer or more specific terms.
|
|
467
|
+
A source must hold one document per id.
|
|
468
|
+
A document over 65,536 body terms is left out and counted: the result's `corpus` reports
|
|
469
|
+
`documents`, `indexed`, `skipped` and `reindexPending`. `searchRowText` is what one document
|
|
470
|
+
contributes to any index, and `textVersion()` names the algorithm, `SEARCH_TEXT_REVISION` and the
|
|
471
|
+
runtime's normalizer fingerprint, so a stored index can tell when its rows need re-deriving.
|
|
472
|
+
|
|
176
473
|
## Hosted client
|
|
177
474
|
|
|
178
475
|
`@superbee/core/hosted-client` is the headless client of a hosted Superbee's sync routes
|
|
@@ -256,14 +553,20 @@ Each write has one request identity and carries the client's binding, a digest o
|
|
|
256
553
|
client mints once (its `within` clients share it). When an answer is lost (a transport failure or
|
|
257
554
|
a deadline after sending), the client looks the identity up on the host and reports what the host
|
|
258
555
|
recorded. The host's outcome route tells a request it never received ("absent") from one still in
|
|
259
|
-
progress ("pending"),
|
|
260
|
-
|
|
556
|
+
progress ("pending"), and the shared transport keeps the two apart. On either answer `write`
|
|
557
|
+
sends the same identity once more, which the host applies at most once; after "pending", an
|
|
558
|
+
earlier delivery is known to be held. Absence counts as
|
|
261
559
|
evidence only within the retention window the host serves in its capabilities
|
|
262
560
|
(`operationsRetentionMs`, less an hour's margin); past it, the result stays `unknown` and nothing
|
|
263
561
|
is resent. The client measures an intent's age by its own clock against the host's window, so that
|
|
264
562
|
margin holds only while the client's clock is within an hour of the host's. A resend refused before
|
|
265
563
|
it reaches the host's execution (no credential, access withdrawn, a spent quota) leaves the result
|
|
266
|
-
`unknown` too, since the first delivery may still commit
|
|
564
|
+
`unknown` too, since the first delivery may still commit; after a "pending" lookup, so does any
|
|
565
|
+
refusal before execution, a gateway's `400` included. The result's `refusal` (`{ code, message }`)
|
|
566
|
+
names that refusal, so an app can say why the write is waiting (for example, access withdrawn)
|
|
567
|
+
while it stays held; only the call that met the refusal reports it. Absence alone never settles
|
|
568
|
+
such a write: a delayed first delivery is authorized when it arrives, so it can still commit once
|
|
569
|
+
access returns. When the lookups themselves fail, or the caller's `signal` aborts after sending, the
|
|
267
570
|
result is `unknown`.
|
|
268
571
|
|
|
269
572
|
An `unknown` write can be resolved later in two ways:
|
|
@@ -271,7 +574,7 @@ An `unknown` write can be resolved later in two ways:
|
|
|
271
574
|
- `lookupWrite(requestId)` makes one outcome lookup and never sends the write. When the host holds
|
|
272
575
|
no outcome yet (absent or pending), the answer stays `unknown`.
|
|
273
576
|
- `settleWrite(requestId)` looks the identity up and, when the host never recorded it within its
|
|
274
|
-
retention window, sends the same identity once more, as `write` would have.
|
|
577
|
+
retention window or holds it pending, sends the same identity once more, as `write` would have.
|
|
275
578
|
|
|
276
579
|
Both answer `{ status: "unrecorded", requestId }` when the intent store holds no write under that
|
|
277
580
|
identity: it was never recorded there, or it already settled and left the store. Both return
|
|
@@ -285,7 +588,8 @@ Until it settles, a write is held in an intent store: its operation, bundle (and
|
|
|
285
588
|
document id, document and digest, expected version, request identity and binding, which is
|
|
286
589
|
everything needed to rebuild the lookup exactly. The default store is in memory and lives as long
|
|
287
590
|
as the client. Pass `intentStore` (`put`, `get`, `delete`, and optionally `list`) to keep intents
|
|
288
|
-
durable across restarts
|
|
591
|
+
durable across restarts (`openHostedClientJournal`, below, is one for Node); lookups use the
|
|
592
|
+
binding recorded with each intent. A service that
|
|
289
593
|
restarts with a durable store calls `heldWrites()` to find the writes that never settled
|
|
290
594
|
(including any recorded but never sent), then settles each with `settleWrite`. `heldWrites()`
|
|
291
595
|
throws `HostedIntentsNotListable` when the store has no `list`. A settled write leaves the store.
|
|
@@ -299,6 +603,254 @@ document (`invalid_input`). Calls on one client that name the same identity run
|
|
|
299
603
|
its capabilities read, or a failure of the intent store to record the write. Document deletion is
|
|
300
604
|
not part of `write`.
|
|
301
605
|
|
|
606
|
+
### Writing a blob
|
|
607
|
+
|
|
608
|
+
`writeBlob` and `deleteBlob` write one ordinary file (a blob: an image, a diagram, any bytes) to a
|
|
609
|
+
hosted bundle. A create names an absent key; a replace and a delete name the head they expect,
|
|
610
|
+
its version **and** content type (a `BlobPin`), so a type-only change is a replace too.
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
const svg = new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>");
|
|
614
|
+
const created = await client.writeBlob("notes", { key: "diagrams/flow.svg", bytes: svg, contentType: "image/svg+xml", expectAbsent: true });
|
|
615
|
+
if (created.status !== "committed") throw new Error(created.status);
|
|
616
|
+
let pin = created.head!; // { key, version, contentType }
|
|
617
|
+
|
|
618
|
+
const replaced = await client.writeBlob("notes", { key: pin.key, bytes: edited, contentType: "image/svg+xml", expected: pin });
|
|
619
|
+
if (replaced.status === "committed") pin = replaced.head!;
|
|
620
|
+
|
|
621
|
+
const read = await client.readBlob("notes", pin.key, { pin }); // bytes verified, or null if no longer at the pin
|
|
622
|
+
const deleted = await client.deleteBlob("notes", { key: pin.key, expected: pin });
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Each call returns one of:
|
|
626
|
+
|
|
627
|
+
- `{ status: "committed", head, changed? }`: applied. `head` is the new head (null after a
|
|
628
|
+
delete); `changed` is false for a replace the head already matched.
|
|
629
|
+
- `{ status: "conflict", reason: "version_conflict", actual }`: the head is not at the expected pin
|
|
630
|
+
(for a create: the key exists). `actual` is the head now, or null when there is none (a delete of
|
|
631
|
+
an absent key). Final for that identity.
|
|
632
|
+
- `{ status: "conflict", reason: "concurrent_change", message }`: another write overlapped this one
|
|
633
|
+
past the host's own retries. Nothing was applied.
|
|
634
|
+
- `{ status: "refused", code, message }`: the host's own refusal, or one the client names. The
|
|
635
|
+
host's codes are those it records under the identity (`backend_unavailable`, ...) and those it
|
|
636
|
+
answers before dispatch and never records (`insufficient_scope` for a grant without blob writes,
|
|
637
|
+
`bundle_not_found`, and `request_capacity` in a `200` or a `429`). A refusal of the second kind
|
|
638
|
+
proves only that this attempt did not run, so it is final only when every earlier attempt of the
|
|
639
|
+
write is proven not applied, which is always so for a write the client sends; a write whose
|
|
640
|
+
earlier attempt was unclear is never sent again, only looked up, and stays `unknown`.
|
|
641
|
+
`insufficient_scope`, `bundle_not_found` and `invalid_input` also drop the held capabilities
|
|
642
|
+
answer, so the next write reads them again. The client's own codes are `invalid_input` (an
|
|
643
|
+
unsafe key, a key under one of Superbee's own record folders or a reserved file, an invalid content type, a body over the host's `blobWriteBytes` or 16 MiB, or a held
|
|
644
|
+
identity reused for another write), `scope_mismatch` (a held write, or a reused `requestId`, recorded
|
|
645
|
+
for another origin or sync prefix) or `unsettled_write` (an earlier write of the same key in the bundle is still
|
|
646
|
+
unsettled: settle or forget it first). Nothing was sent for a client-named refusal.
|
|
647
|
+
- `{ status: "held", deletions, baseline }`: the host holds this mass delete (`428
|
|
648
|
+
deletions_held`). Nothing was applied. A person confirms the count, and
|
|
649
|
+
`settleBlobWrite(requestId, { acceptDeletes })` with at least `deletions` sends it again; without
|
|
650
|
+
`acceptDeletes` it stays held and nothing is sent, and with fewer it throws
|
|
651
|
+
`HostedBlobDeletionsNotAccepted`, sending nothing. `deleteBlob(..., { acceptDeletes })` sends the
|
|
652
|
+
confirmation with the first attempt.
|
|
653
|
+
- `{ status: "unknown" }`: the host may or may not hold the write.
|
|
654
|
+
|
|
655
|
+
Every result names its `requestId`, `key` and `operation`.
|
|
656
|
+
|
|
657
|
+
**The capability gate.** A blob write needs the bundle's `blobWrites: "allowed"` and its
|
|
658
|
+
`blobWriteBytes` bound, from the same held capabilities answer `write` uses. When the host serves
|
|
659
|
+
the bundle no blob writes (an older host's answer reads as refused), `writeBlob` and `deleteBlob`
|
|
660
|
+
throw `HostedBlobWritesNotServed`, a `HostedRouteNotServed` (`isRouteNotServed` is true), before
|
|
661
|
+
anything is journaled or sent. This differs from `write()`, which answers a bundle without writes
|
|
662
|
+
with the result `refused: read_only`: the blob routes are a newer capability a caller probes for, as
|
|
663
|
+
it probes for any route a host may lack. The checks a send would otherwise fail (the capability, the
|
|
664
|
+
byte bound, the key and the content type) all run against that one answer before the write is
|
|
665
|
+
journaled, and the delivery is handed the same answer, so the two cannot disagree.
|
|
666
|
+
|
|
667
|
+
**Exactly once, never resent after it may have left.** Each blob write is journaled under one
|
|
668
|
+
request identity (with the client's binding, the bundle and workspace and the bytes), durably
|
|
669
|
+
claimed, then sent once. After an unclear answer the client looks the identity up on the host
|
|
670
|
+
(`/outcome`, under the binding recorded with the write) and reports what the host recorded. Unlike a
|
|
671
|
+
document write, an `absent` lookup is never permission to send again: once an attempt may have left,
|
|
672
|
+
a blob write is only ever looked up. A send proven never to have left (the credential provider
|
|
673
|
+
failed or was denied, the signal was already aborted, the identity was malformed, a local check
|
|
674
|
+
failed) is rolled back instead: `writeBlob` throws `HostedBlobWriteNotSent` with its `requestId` and
|
|
675
|
+
`reason`, and the write stays held and resendable (`held: true`), so `settleBlobWrite` or the same
|
|
676
|
+
`writeBlob` call (same `requestId`, same input) sends it. A transport failure after the request was
|
|
677
|
+
handed to the carrier is `unknown`.
|
|
678
|
+
|
|
679
|
+
**Recovery.** `heldBlobWrites()` lists every unsettled blob write (`requestId`, `bundleId`,
|
|
680
|
+
`workspace`, `key`, `operation`, `state`, `attempts`, `resendable`, `hold`, `createdAt`) and sweeps any
|
|
681
|
+
settled one a crash left behind.
|
|
682
|
+
|
|
683
|
+
- `lookupBlobWrite(requestId)` makes one outcome lookup and never sends. A write never sent (or
|
|
684
|
+
proven not applied) answers `{ status: "pending" }` without a request, and a held delete `held`.
|
|
685
|
+
- `settleBlobWrite(requestId, { acceptDeletes? })` submits the write when its state is pending or
|
|
686
|
+
unknown and every attempt is proven not applied (`attempts === notAppliedThrough`); otherwise it
|
|
687
|
+
only looks it up, and an absent lookup is `unknown`.
|
|
688
|
+
- `forgetBlobWrite(requestId)` drops an `unknown` write once one fresh lookup answers `absent`,
|
|
689
|
+
returning `{ status: "forgotten" }`. **The earlier delivery may still commit**; the host's
|
|
690
|
+
compare-and-swap is what keeps a later write from losing that update. Any other record is
|
|
691
|
+
`{ status: "kept", reason: "not_unknown" }` (settle an `in_flight` one first), a lookup that fails,
|
|
692
|
+
is refused or is malformed is `kept` with `lookup_unavailable`, and a lookup that finds the outcome
|
|
693
|
+
settles the write instead. A forgotten identity is no longer held, so `writeBlob` with that same
|
|
694
|
+
`requestId` journals and uploads it again under that identity. That is safe: the host answers a
|
|
695
|
+
delivery it recorded under the identity with the recorded result, and one it did not record is
|
|
696
|
+
checked against the expected pin like any write.
|
|
697
|
+
|
|
698
|
+
All three answer `{ status: "unrecorded", requestId }` when the journal holds no blob write under the
|
|
699
|
+
identity. A reused `requestId` with the same input resumes (settles) the held write; with different
|
|
700
|
+
input it is `refused: invalid_input` and nothing is admitted. Calls are serialized per request
|
|
701
|
+
identity and per key (the journal's guard covers a key across bundles), so concurrent calls of one
|
|
702
|
+
client never trip each other.
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
// A service restarting after a crash: settle what it held, forgetting only proven-absent unknowns.
|
|
706
|
+
for (const held of await client.heldBlobWrites()) {
|
|
707
|
+
const settled = await client.settleBlobWrite(held.requestId);
|
|
708
|
+
if (settled.status === "unknown") console.warn(`blob write ${held.requestId} is still unknown`); // forgetBlobWrite once you choose to move on
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**Reads.** `readBlob(bundleId, key, { pin })` requires the pin: it reads exactly that version and
|
|
713
|
+
content type, verifies length, digest and type, and answers null when the key is no longer at the pin
|
|
714
|
+
(the host's `409`). `blobHead(bundleId, key)` is the explicit lookup of one head (or null), and
|
|
715
|
+
`blobHeads(bundleId)` the whole listing; both read the full verified listing (up to 10,000 heads in
|
|
716
|
+
4 MiB pages), since the host has no single-key head route yet.
|
|
717
|
+
|
|
718
|
+
**Keys.** `isOrdinaryBlobKey` is the one key policy every blob writer uses, this client and the
|
|
719
|
+
CLI's checkout sync alike: a safe key that is not a reserved file (`index.md`, `log.md`) and does not
|
|
720
|
+
sit under Superbee's own record folders (`views/`, `pages/`, their registries, `conventions/`,
|
|
721
|
+
`recipes/`, `references/`, compared case-insensitively). Any other key is refused `invalid_input`
|
|
722
|
+
before any request.
|
|
723
|
+
|
|
724
|
+
**Aborts.** A `signal` aborted before the write is admitted throws its reason and journals nothing;
|
|
725
|
+
that includes an abort while the capabilities are still being read, so a hung credential provider
|
|
726
|
+
never holds the call (or its key) past the caller's abort. Once admitted, a write is held until it
|
|
727
|
+
settles, and an abort before its send leaves it resendable (`HostedBlobWriteNotSent`).
|
|
728
|
+
|
|
729
|
+
**Journal.** The default blob journal is in memory (`createMemoryBlobJournal`), owned by the client.
|
|
730
|
+
Give a blob journal to one client (its `within` clients share it). Two clients over one journal
|
|
731
|
+
coordinate only through its per-key guard: concurrent writes of different identities still settle,
|
|
732
|
+
but two calls on the same request identity from both clients can fail with a raw journal conflict.
|
|
733
|
+
Pass `blobJournal` to keep blob writes across restarts: in Node, `openHostedClientJournal(...).blobs`
|
|
734
|
+
(below). `driveJournaledBlob(journal, context, requestId, mode)` is the owner of the journaled
|
|
735
|
+
sequence (admit, claim, deliver, persist, retire) for a caller with its own journal and premises; its
|
|
736
|
+
`premises.snapshot(key, step)` returns each transaction's guard plus the caller's own metadata
|
|
737
|
+
changes, checked atomically with it.
|
|
738
|
+
|
|
739
|
+
**Limitations.**
|
|
740
|
+
|
|
741
|
+
- Lookups currently need `blobWrites`: a grant downgraded to read-only cannot settle its earlier
|
|
742
|
+
blob writes until the host authorizes the outcome route on its own (a hosted follow-up).
|
|
743
|
+
- A lookup across principals is unverified: a new token of the same principal finds its outcomes
|
|
744
|
+
(the binding travels in the journal), but what another principal's lookup gets is not yet proven.
|
|
745
|
+
- Every journal record of a write carries its bytes, and each transaction validates the record.
|
|
746
|
+
Validation is cached per payload, so a write costs about 0.17 s per MiB in Node (3 MiB about
|
|
747
|
+
0.5 s, 16 MiB about 2.6 s, measured with the in-memory journal); without the cache it was about
|
|
748
|
+
1.7 s per MiB (16 MiB about 28 s). The first validation of a payload still runs on the caller's
|
|
749
|
+
thread in one slice, about 1.2 s for 16 MiB.
|
|
750
|
+
|
|
751
|
+
### Inbox
|
|
752
|
+
|
|
753
|
+
`createHostedInbox(client, { tenantId })` is a person's hosted inbox in one workspace, over the
|
|
754
|
+
same client (its credential, deadline and abort; a `within` client serves too). It reaches the
|
|
755
|
+
attention routes, `POST <prefix>/attention/<operationId>`, and names the workspace in the
|
|
756
|
+
`Superbee-Workspace` header on every request, since a sync sign-in spans all of the person's
|
|
757
|
+
workspaces. Notifications are a hosted capability: a host without these routes answers
|
|
758
|
+
`HostedRouteNotServed`, and nothing here emulates a local inbox.
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
import { createHostedInbox } from "@superbee/core/hosted-client";
|
|
762
|
+
|
|
763
|
+
const identity = await client.whoami();
|
|
764
|
+
const inbox = createHostedInbox(client, { tenantId: identity.tenantIds[0]!, via: "my-agent" });
|
|
765
|
+
const receipt = await inbox.request({ recipient: { kind: "principal", principalId: identity.principalId }, kind: "needs_input", note: "Ready for you" });
|
|
766
|
+
const page = await inbox.list({ threadId: receipt.threadId, afterSeq: 0 });
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
The methods are `list`, `threads`, `summary`, `get` (does not mark read), `open` (marks read),
|
|
770
|
+
`ack` (at the revision read), `request` (send) and `cancel` (withdraw your own send), with the
|
|
771
|
+
open contract's shapes (`InboxEntry`, `InboxThread`, `AttentionReceipt` and the inputs). A
|
|
772
|
+
recipient is a principal id; to notify yourself, use `whoami().principalId`. A document target
|
|
773
|
+
names `whoami().targetAuthorityKey` as its `authorityKey` and the document's current version; a
|
|
774
|
+
host that reports no key (the field is absent, as on every `/sync/v1` host today) cannot take one yet.
|
|
775
|
+
|
|
776
|
+
`request` mints one UUIDv7 ticket per send (or takes the one you pass). When an answer is lost or
|
|
777
|
+
the host marks its refusal retryable, it sends the same body under the same ticket again
|
|
778
|
+
(`sendAttempts`, default 3), which the host answers as a replay of the first send, never as a second
|
|
779
|
+
item. A send still unclear after that throws `HostedAttentionOutcomeUnknown` with its `ticket`:
|
|
780
|
+
`request({ ...input, ticket })` within 24 hours settles it. Refusals are
|
|
781
|
+
`HostedAttentionRefusal` (a `RemoteError`) with the catalog's `code` (`attention_disabled`,
|
|
782
|
+
`not_found`, `target_unavailable`, `target_unsupported`, `quota_exceeded`, `request_conflict`,
|
|
783
|
+
`version_conflict`, `ticket_expired` with `serverTime`, `workspace_not_available` and the rest),
|
|
784
|
+
`retryable`, and a message in this client's words: the host's message and hint are never repeated.
|
|
785
|
+
|
|
786
|
+
### Durable intent store (Node only)
|
|
787
|
+
|
|
788
|
+
`openHostedClientJournal` from `@superbee/core/hosted-client/node` is a durable `intentStore`, and its
|
|
789
|
+
`blobs` a durable `blobJournal`, so a short-lived process that exits with a write `unknown` can recover
|
|
790
|
+
it after a restart.
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
import { createHostedClient } from "@superbee/core/hosted-client";
|
|
794
|
+
import { openHostedClientJournal } from "@superbee/core/hosted-client/node";
|
|
795
|
+
|
|
796
|
+
const journal = await openHostedClientJournal({ directory: path.join(stateDir, "superbee-writes") });
|
|
797
|
+
const client = createHostedClient({ target, credentials, intentStore: journal, blobJournal: journal.blobs });
|
|
798
|
+
for (const held of await client.heldWrites()) await client.settleWrite(held.requestId);
|
|
799
|
+
for (const held of await client.heldBlobWrites()) await client.settleBlobWrite(held.requestId);
|
|
800
|
+
// ... writes ...
|
|
801
|
+
await journal.close();
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
The journal is a `FileJournaledBackend` directory: an fsynced, checksummed log with snapshot
|
|
805
|
+
compaction. Its on-disk format is a supported compatibility promise: a format bump ships a reader
|
|
806
|
+
for every earlier layout, and an older reader refuses a newer store with `FileJournalFormatError`
|
|
807
|
+
rather than misreading it. The current format is 3 (see `JOURNALED-BACKENDS.md`). Each unsettled write is one metadata row, `hosted-client.write.<requestId>`,
|
|
808
|
+
holding the whole document and the binding it was sent under, so a new process with a new token
|
|
809
|
+
looks it up exactly as the first would have. A settled write is removed. Each unsettled blob write is
|
|
810
|
+
one row of the backend's own blob intent family, with its bytes, under the same transition rules as
|
|
811
|
+
the in-memory journal; a settled one is retired, and since every record of a blob write carries its
|
|
812
|
+
bytes, retiring or forgetting one compacts the store once its log or snapshot is past 1 MiB. Metadata
|
|
813
|
+
a blob write carries may not name the journal's own rows (`hosted-client.*`) or a checkout's
|
|
814
|
+
blob-sync state.
|
|
815
|
+
|
|
816
|
+
- **Ownership.** Open takes an exclusive same-user lock on the directory until `close()`. Another
|
|
817
|
+
process waits `lock.waitMs` (default 5,000 ms) and then gets `FilesystemMutationLockError`. A
|
|
818
|
+
lock left by a process that died on this host is reclaimed.
|
|
819
|
+
- **Local file system only.** The lock lives in a per-user runtime directory on this host, so it
|
|
820
|
+
cannot exclude a process on another host. Keep the journal on a local disk, never on NFS, SMB
|
|
821
|
+
or another shared mount. Open does not detect a network mount; this is the operator's rule.
|
|
822
|
+
- **Privacy.** The journal holds whole documents. A directory open creates is mode 0700, and its
|
|
823
|
+
files 0600. On POSIX, an existing directory that is a symbolic link, is owned by another user,
|
|
824
|
+
or grants its group or others any permission, is refused as `HostedClientJournalError`
|
|
825
|
+
`JOURNAL_DIRECTORY_NOT_PRIVATE`. The check runs again once the lock is held, and a directory
|
|
826
|
+
replaced in between is refused too. The directory is the journal's alone: an existing one may
|
|
827
|
+
hold only `store.log`, `store.snapshot` and `store.snapshot.tmp`, and any other entry (an
|
|
828
|
+
operating system's `.DS_Store` included) is refused as `JOURNAL_NOT_HOSTED_CLIENT`, as is a
|
|
829
|
+
`store.log` that is not a journal's log, and a store file that is a symbolic link, a
|
|
830
|
+
directory, has more than one link, or (on POSIX) belongs to another user. The listing and the
|
|
831
|
+
files are checked again under the lock. Another process of the same user changing the
|
|
832
|
+
directory while it opens is out of scope: in that window the open can truncate a torn or
|
|
833
|
+
foreign log tail.
|
|
834
|
+
- **Marker.** A new journal records `hosted-client.journal = { schemaVersion: 1 }` in its first
|
|
835
|
+
transaction. Open refuses, as `JOURNAL_NOT_HOSTED_CLIENT`, a store with rows but no marker or one
|
|
836
|
+
holding documents, blob files or a checkout's blob-sync state, so it is never pointed at a CLI
|
|
837
|
+
checkout store. A marked journal holding blob write intents is its own and reopens. A marker of another schema is `JOURNAL_SCHEMA_UNSUPPORTED`. Open decides on a read-only
|
|
838
|
+
pass first, so a refused directory's files are left byte for byte. A crash before the marker
|
|
839
|
+
lands leaves an empty store, which the next open marks.
|
|
840
|
+
- **Size.** Once the store is adopted, the log is compacted into the snapshot whenever it grows past the greater of 1 MiB
|
|
841
|
+
(`HOSTED_CLIENT_JOURNAL_COMPACT_FLOOR_BYTES`) and twice the last snapshot, so steady writing keeps
|
|
842
|
+
it near 1 MiB. The snapshot, which holds every write still unsettled, and any one transaction are
|
|
843
|
+
bounded at 256 MiB. A write that would pass that bound is refused before anything is recorded,
|
|
844
|
+
and its `write` throws. Unsettled writes count toward the bound until `settleWrite` resolves them.
|
|
845
|
+
- **Ordering.** Unlike the in-memory store, each call takes effect when its asynchronous steps
|
|
846
|
+
complete, so unawaited overlapping calls on one request identity can land out of call order.
|
|
847
|
+
Await each call before the next, as the client does.
|
|
848
|
+
- **Damage.** `FileJournalCorruptError` (a damaged record before the log's end or a damaged
|
|
849
|
+
snapshot) and `FileJournalFormatError` (a newer format) refuse the open and change no file. A torn
|
|
850
|
+
final record, which was never acknowledged, is truncated. Nothing is repaired automatically:
|
|
851
|
+
moving the directory aside starts an empty journal and loses the writes it held, which can then
|
|
852
|
+
be settled only by reading the documents on the host.
|
|
853
|
+
|
|
302
854
|
### Export download (Node only)
|
|
303
855
|
|
|
304
856
|
`@superbee/core/hosted-client/node` downloads and verifies a hosted bundle's portable export. It
|
|
@@ -359,3 +911,68 @@ private directory; callers own its cleanup and isolation while publishing. No ta
|
|
|
359
911
|
inventory SHA-256. Keep that evidence outside the directory and private when it identifies a
|
|
360
912
|
private source. Handles cannot be reconstructed from JSON or moved between independently bundled
|
|
361
913
|
module instances; modified handles are refused. This surface requires Node, not a browser or Worker.
|
|
914
|
+
|
|
915
|
+
## Built-in recipes and Kind authoring
|
|
916
|
+
|
|
917
|
+
`@superbee/core/builtin-recipes` is an opt-in catalog: `builtinNames()` lists names,
|
|
918
|
+
`builtinRecipeFiles(name)` returns detached source files, and `loadBuiltinRecipe(name)` returns
|
|
919
|
+
the existing parser's result or `null` for an unknown name. The generic parser, package root,
|
|
920
|
+
and `initBundle` do not import the catalog or install recipes.
|
|
921
|
+
|
|
922
|
+
`@superbee/core/kind-authoring` provides inference, forecasts, dismissal classification, templates,
|
|
923
|
+
and guarded draft/apply/dismiss operations on a caller-selected `Bundle` backend. An outside app
|
|
924
|
+
can use the existing backend and mutation seams without a CLI subprocess:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
import { MemoryBackend } from "@superbee/core/memory-backend";
|
|
928
|
+
import { query } from "@superbee/core/engine";
|
|
929
|
+
import { mutateDocument } from "@superbee/core/document-mutation";
|
|
930
|
+
import { buildKindRegistry } from "@superbee/core/kinds";
|
|
931
|
+
import { loadBuiltinRecipe } from "@superbee/core/builtin-recipes";
|
|
932
|
+
import { installRecipe } from "@superbee/core/recipe-lifecycle";
|
|
933
|
+
import { kindInstanceCandidate, prepareKindDraft, applyKindDraft } from "@superbee/core/kind-authoring";
|
|
934
|
+
|
|
935
|
+
const backend = new MemoryBackend(), bundle = { root: "memory://project", backend };
|
|
936
|
+
await backend.writeReserved("", "index.md", "---\nokf_version: '0.2'\n---\n# Project\n");
|
|
937
|
+
const loaded = loadBuiltinRecipe("work-tracking");
|
|
938
|
+
if (!loaded?.ok) throw new Error("Recipe unavailable");
|
|
939
|
+
await installRecipe(bundle, loaded.recipe);
|
|
940
|
+
const registry = buildKindRegistry(await query(backend, { prefix: "conventions/" }));
|
|
941
|
+
const task = registry.kinds.get("Task")!;
|
|
942
|
+
const candidate = kindInstanceCandidate(task, { title: "First task", superbee_progress_status: "todo" });
|
|
943
|
+
await mutateDocument({ bundle, id: "tasks/first", mode: "create-only", registry, strict: true,
|
|
944
|
+
buildCandidate: () => candidate });
|
|
945
|
+
|
|
946
|
+
// Once an ungoverned type has existing instances, inspect its draft before applying.
|
|
947
|
+
const text = { inferredBody: () => "The accepted shape of existing research records.\n" };
|
|
948
|
+
const plan = await prepareKindDraft(bundle, "Research", text); // requires existing Research docs
|
|
949
|
+
// Present candidateDoc, warnings and promotions; after acceptance:
|
|
950
|
+
await applyKindDraft(bundle, "Research", plan.token, text);
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
`kindInstanceCandidate` accepts already-typed field values. Existing validators own their meaning;
|
|
954
|
+
there is no CLI flag coercion or path-alias resolution. Supply a canonical id and use strict
|
|
955
|
+
create-only mutation. The template keeps `type` equal to `governs`, preserves own prototype-shaped
|
|
956
|
+
fields, scaffolds sections only when body is omitted, and retains an explicit empty body.
|
|
957
|
+
|
|
958
|
+
Public draft/apply/dismiss operations trim type names and refuse blank or non-string names before
|
|
959
|
+
any backend access, matching convention parsing. `prepareKindDraft` selects a catalog definition
|
|
960
|
+
or infers from instances. Its token binds candidate
|
|
961
|
+
bytes (including supplied `inferredBody`/`inferredDescription`), edition, presence statistics and
|
|
962
|
+
warning forecast. Pass the same prose options to `applyKindDraft`, which recomputes the plan and
|
|
963
|
+
checks edition and declaration-free redraft eligibility against every fresh CAS head. New
|
|
964
|
+
conventions are expect-absent creates; accepted redrafts replace dismissal prose in place and
|
|
965
|
+
report dropped links. `dismissKind` accepts presentation-owned description/body, records no schema
|
|
966
|
+
obligations, refuses a real schema, and is idempotent. Definition writes retain recipe
|
|
967
|
+
installation's generation-clock opt-out. `KindAuthoringError.reason` identifies domain refusals;
|
|
968
|
+
normal mutation/storage errors retain their types.
|
|
969
|
+
|
|
970
|
+
Presentations with an established mutation adapter may pass the options from
|
|
971
|
+
`prepareKindDraftMutation` or `prepareKindDismissal` through that adapter. These are the same
|
|
972
|
+
specifications used by public apply/dismiss; keep preparation adjacent to mutation. Outside apps
|
|
973
|
+
normally use `applyKindDraft`/`dismissKind`, which own the read-and-apply sequence. No cross-document
|
|
974
|
+
transaction or authorization boundary is added.
|
|
975
|
+
|
|
976
|
+
The packed proof runs the public workflow over `MemoryBackend` in Node and a browser bundle without
|
|
977
|
+
Node globals. This proves source/package availability. Hosted provisioning routes, MCP modeling
|
|
978
|
+
tools, and publication of a core version remain separate adoption and release decisions.
|