|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25467 cut 33] The ADR-0067 commit row that groups a turn's published |
| 5 | + * artifacts, from `ObjectStackProtocolImplementation`, moved out of |
| 6 | + * `protocol.ts` whole: `recordPackageCommit` and the named INSERT it calls, |
| 7 | + * `persistPackageCommitRow` (named so the durability log-level rule can |
| 8 | + * declare it). |
| 9 | + * |
| 10 | + * A MOVE, not a redesign. Each function below is the body of the |
| 11 | + * `ObjectStackProtocolImplementation` method of the same name as it stood, |
| 12 | + * re-indented one level and otherwise unedited. |
| 13 | + * |
| 14 | + * ## How the moved bodies reach the class |
| 15 | + * |
| 16 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 17 | + * header carries the full reasoning), as `protocol-batch-data.ts` applies it. |
| 18 | + * The class keeps each method — the published `dist/*.d.ts` lists its name, |
| 19 | + * docblock and declared signature — and its body is one literal of exactly |
| 20 | + * the {@link PackageCommitRecordHost} members it reads, plus a `.call`; a |
| 21 | + * body that reads no member is called as a plain function. The `engine` field |
| 22 | + * and the noted-once flag are handed over as getters (and the flag as a |
| 23 | + * setter), so each read and write reaches the class field as it stands at |
| 24 | + * call time; the call to `persistPackageCommitRow` is a closure, so it still |
| 25 | + * reaches the class method. Every caller keeps calling the class methods. |
| 26 | + * |
| 27 | + * What the bodies read from elsewhere, they import from where `protocol.ts` |
| 28 | + * imports it. Nothing re-exports this module, so the published surface does |
| 29 | + * not move. |
| 30 | + */ |
| 31 | + |
| 32 | +import { isMissingTableError } from '@objectstack/metadata/errors'; |
| 33 | +import type { CommitPlanItem, ObjectStackProtocolImplementation } from './protocol.js'; |
| 34 | + |
| 35 | +/** |
| 36 | + * The `ObjectStackProtocolImplementation` members the functions below read — |
| 37 | + * every one of them, and no other; each function's `this` is the `Pick` of |
| 38 | + * the members it reads. Each is typed AS the class member (an indexed access |
| 39 | + * on the class), so a change to a member's signature on the class is a change |
| 40 | + * here too, and naming a member the class does not have is a compile error. |
| 41 | + */ |
| 42 | +export interface PackageCommitRecordHost { |
| 43 | + commitStoreUnprovisionedNoted: ObjectStackProtocolImplementation['commitStoreUnprovisionedNoted']; |
| 44 | + readonly engine: ObjectStackProtocolImplementation['engine']; |
| 45 | + readonly persistPackageCommitRow: ObjectStackProtocolImplementation['persistPackageCommitRow']; |
| 46 | +} |
| 47 | + |
| 48 | +/** The body of `ObjectStackProtocolImplementation#persistPackageCommitRow`; the method's docblock there is its contract. */ |
| 49 | +export async function persistPackageCommitRow(this: Pick<PackageCommitRecordHost, 'engine'>, row: Record<string, unknown>): Promise<void> { |
| 50 | + // [#21911] The explicit system opt-in — see recordMetadataAudit. |
| 51 | + await this.engine.insert('sys_metadata_commit', row, { context: { isSystem: true } }); |
| 52 | +} |
| 53 | + |
| 54 | +/** The body of `ObjectStackProtocolImplementation#recordPackageCommit`; the method's docblock there is its contract. */ |
| 55 | +export async function recordPackageCommit(this: Pick<PackageCommitRecordHost, 'commitStoreUnprovisionedNoted' | 'persistPackageCommitRow'>, args: { |
| 56 | + packageId: string; |
| 57 | + operation: 'apply' | 'revert'; |
| 58 | + message?: string; |
| 59 | + actor?: string; |
| 60 | + aiModel?: string; |
| 61 | + parentCommitId?: string; |
| 62 | + items: CommitPlanItem[]; |
| 63 | + eventSeqStart?: number; |
| 64 | + eventSeqEnd?: number; |
| 65 | +}): Promise<{ commitId: string } | null> { |
| 66 | + try { |
| 67 | + const commitId = 'cmt_' + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' |
| 68 | + ? crypto.randomUUID() |
| 69 | + : `${args.eventSeqEnd ?? 0}-${args.items.length}-${args.packageId}`); |
| 70 | + await this.persistPackageCommitRow({ |
| 71 | + id: commitId, |
| 72 | + package_id: args.packageId, |
| 73 | + operation: args.operation, |
| 74 | + ...(args.message ? { message: args.message } : {}), |
| 75 | + ...(args.actor ? { actor: args.actor } : {}), |
| 76 | + ...(args.aiModel ? { ai_model: args.aiModel } : {}), |
| 77 | + ...(args.parentCommitId ? { parent_commit_id: args.parentCommitId } : {}), |
| 78 | + ...(args.eventSeqStart !== undefined ? { event_seq_start: args.eventSeqStart } : {}), |
| 79 | + ...(args.eventSeqEnd !== undefined ? { event_seq_end: args.eventSeqEnd } : {}), |
| 80 | + items: JSON.stringify(args.items), |
| 81 | + item_count: args.items.length, |
| 82 | + created_at: new Date().toISOString(), |
| 83 | + }); |
| 84 | + return { commitId }; |
| 85 | + } catch (error) { |
| 86 | + // [#9066] The publish keeps its outcome — `null` still comes back |
| 87 | + // and the caller still reports success — but the silence is over. |
| 88 | + // |
| 89 | + // BENIGN: `sys_metadata_commit` was never provisioned. That is a |
| 90 | + // real deployment state (a first boot, or an env kernel composed |
| 91 | + // without the commit log — the exact case |
| 92 | + // `@objectstack/metadata`'s plugin note records), and it is a |
| 93 | + // CONFIGURATION fact, not a store that broke: it is the same on |
| 94 | + // every publish and it is fixed in one place. Stated once per |
| 95 | + // instance, at `info`, per this card's ruling that an unprovisioned |
| 96 | + // commit store stays silent or informational. |
| 97 | + // |
| 98 | + // EVERYTHING ELSE — a connection drop, a timeout, a permission |
| 99 | + // denial, schema drift on that one table — is a write that was |
| 100 | + // supposed to land and did not, while the publish it describes DID. |
| 101 | + // `error`, per AGENTS.md "Degradation log levels", and the line |
| 102 | + // owes the two things that rule requires: the CONSEQUENCE (this |
| 103 | + // turn is not revertible, and the system will keep looking healthy) |
| 104 | + // and the FIX (repair write access to the commit store). |
| 105 | + // |
| 106 | + // NOT rethrown, and not surfaced to the caller: the artifacts are |
| 107 | + // already live and unwinding them over a missing history row would |
| 108 | + // be strictly worse than losing the row. Telling the CALLER that |
| 109 | + // the turn is unrevertible is a separate question (a response-field |
| 110 | + // change the #8896 ruling forbids for this family) and deliberately |
| 111 | + // NOT decided here. |
| 112 | + if (isMissingTableError(error, 'sys_metadata_commit')) { |
| 113 | + if (!this.commitStoreUnprovisionedNoted) { |
| 114 | + this.commitStoreUnprovisionedNoted = true; |
| 115 | + console.info( |
| 116 | + '[Protocol] sys_metadata_commit is not provisioned — publishes and reverts ' |
| 117 | + + 'succeed but record no ADR-0067 commit row, so no turn can be reverted ' |
| 118 | + + '(the revert plan of every turn is dropped). Fix: provision the commit ' |
| 119 | + + 'store — it is registered alongside sys_metadata_history by ' |
| 120 | + + "@objectstack/metadata's plugin; run schema sync. Said once per protocol instance.", |
| 121 | + ); |
| 122 | + } |
| 123 | + return null; |
| 124 | + } |
| 125 | + const reason = (error as { message?: string } | undefined)?.message ?? String(error); |
| 126 | + console.error( |
| 127 | + `[Protocol] sys_metadata_commit write FAILED for package '${args.packageId}' ` |
| 128 | + + `(${args.operation}, ${args.items.length} item(s)): ${reason}. ` |
| 129 | + + 'The publish itself SUCCEEDED and reports success, so nothing looks broken — but ' |
| 130 | + + "this turn's revert plan was not persisted: the turn is absent from the package " |
| 131 | + + 'commit timeline and can never be reverted, and every further publish loses its ' |
| 132 | + + 'own the same way until this is repaired. Fix: restore write access to ' |
| 133 | + + 'sys_metadata_commit (connectivity, permissions, or schema drift on that table).', |
| 134 | + ); |
| 135 | + return null; |
| 136 | + } |
| 137 | +} |
0 commit comments