cultcache-ts docs: unique indexes, roles, per-key migration rule and the Bifrost key table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9a42823075
commit
f7224ef0b4
3 changed files with 65 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <k1> and <k2> (<schema name>) both hold unique index '<alias>'; 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_<commandId>_<activityKind>`, 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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue