diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index eefea3b5..503cde36 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +### Breaking + +- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank + value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical + text (booleans `True`/`False`, integers as digits, doubles as the exact expansion, blank values never indexed), so + an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names + the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is + in the README; classify every key before bumping. + ### Changed - Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index dc53c42d..79825620 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -93,12 +93,16 @@ const itemDocument = defineDocumentType({ schemaVersion: "item.v1", schema: z.object({ name: z.string(), + sku: z.string(), category: z.string(), value: z.number().int(), }), name: "name", indexes: { - category: "category", + sku: "sku", // unique: a sku names one item + }, + roles: { + category: "category", // a selection alias: many items share a category }, }); @@ -122,6 +126,7 @@ await cache.pullAllBackingStores(); await cache.put(itemDocument, "item:potion", { name: "Potion", + sku: "POT-001", category: "Consumable", value: 50, }); @@ -132,7 +137,7 @@ await cache.putGlobal(settingsDocument, { }); const potion = cache.getByName(itemDocument, "Potion"); -const consumable = cache.getByIndex(itemDocument, "category", "Consumable"); +const bySku = cache.getByIndex(itemDocument, "sku", "POT-001"); const settings = cache.getRequiredGlobal(settingsDocument); ``` @@ -194,7 +199,7 @@ A document type has exactly one home store ## Name, Index, and Global Semantics -`name` and `indexes` can be declared on the document definition: +`name`, `indexes` and `roles` can be declared on the document definition: ```ts const playerDocument = defineDocumentType({ @@ -202,17 +207,61 @@ const playerDocument = defineDocumentType({ schema: playerSchema, name: "displayName", indexes: { - faction: "faction", - email: "email", + email: "email", // unique lookup + }, + roles: { + faction: "faction", // selection alias, many players share one }, }); ``` +`indexes` are unique (variants:ruling:unique-indexes; C# `[CultIndex]`, Rust `#[cultcache(index)]`). Within one type +and one alias no two records hold one non-blank value, judged on the result of every write and of every load; a +refusal reads `Records and () both hold unique index ''; an index value names one record.` +(keys in ordinal order, never the value), and writes nothing. A blank value (absent, empty, or only whitespace) is +never indexed and never conflicts. Two types may share an alias and a value. Index values are compared as the C# +reference renders them: strings as is (never trimmed), `True`/`False`, integers as digits, a non-integral number as +its exact decimal expansion; NaN and infinities have no value. `roles` name selection and reference aliases (the +member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and +are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two. + +### Migrating an existing `indexes` key + +Apply per key, here and in a consumer repo at its pin bump: + +1. Nothing looks records up by the key (`getByIndex`, `getKeyByIndex`, `getIdByIndex`; Python `get_by_index`, + `get_key_by_index`, `node.get_by_index`, `watch_by_index`; Rust `get_by_index`): it is a role. Move it to `roles`; + where a definition lists `members` explicitly, delete it from `indexes` and keep the member's `indexAlias` (the + catalog is the same). +2. Something looks it up: it stays unique only when two live records can never hold one non-blank value, because the + record key is derived from that value alone, or the value is an outside identity (email, sku, external id). + Enums, statuses, kinds, categories, parent or foreign ids, shared hashes and timestamps are roles; a lookup on + one already answered an arbitrary record, so move it to a selection (`any_of`) or `getAll` plus a filter. +3. Before bumping, open each real `.cc` once with the new definitions. A refusal names both keys and the alias and + writes nothing. + +Misclassifying costs differently each way. A role declared unique: the second record holding a shared value is +refused at commit, and a store already holding one refuses to open, so that store is down until the key is +reclassified (loud, names both keys and the alias). A unique key declared a role: lookups by it answer nothing and +duplicates stop being refused. + +The Bifrost TS stores (at 781e6ea) call no lookup anywhere, so by rule 1 every key is a role and no key becomes unique; +the edit deletes each `indexes` block, the catalog unchanged: + +| Definition | Keys | Verdict | +|---|---|---| +| `update-request` (`tools/agent-transport.mjs`) | `targetRepoName`, `targetAgentIdentity`, `status` | role: many requests per repo and agent; status is an enum | +| `crossing_receipt` (`tools/bifrost-crossing-documents.mjs`) | `commandId`, `crossingKind`, `status` | role: receipts are `crossing__`, several per command; the rest are enums | +| release authority (`tools/bifrost-repository-release-authority.mjs`) | `repositoryFullName`, `upstreamRef`, `commitSha`, `status` | role: the authority id is derived from repo, ref and sha, so one sha under two refs is two records | +| `topic` (`tools/governance-threads.mjs`) | `jurisdictionRepoName`, `jurisdictionAgentIdentity`, `status` | role: many topics per jurisdiction | +| `comment` (`tools/governance-threads.mjs`) | `topicId`, `authorId`, `stance` | role: many comments per topic; stance is an enum | +| `advertisement` (`tools/provider-advertisement.mjs`) | `serviceName` | role: one record holds it today, but nothing looks it up | + You can also register them later if you feel like changing the machine after boot: ```ts await cache.registerNameLookup(playerDocument, "displayName"); -await cache.registerIndex(playerDocument, "faction", "faction"); +await cache.registerIndex(playerDocument, "email", "email"); ``` Registration, `addBackingStore`, `pullAllBackingStores` and every write and diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 07996956..7d1b9a6a 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -66,6 +66,7 @@ Current message families: - `cultnet.snapshot_response.v0` - `cultnet.snapshot_response_raw.v0` - `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet) +- `cultnet.database_change_raw.v0` (optional `commitId` and `commitCount >= 2` mark the changes of one commit; carried and validated here, no TS code groups or applies by them) - `cultnet.sample.change_name.v0` - `cultnet.sample.chat.v0`