diff --git a/src/services/guard.ts b/src/services/guard.ts index 28db6dc..61dc68e 100644 --- a/src/services/guard.ts +++ b/src/services/guard.ts @@ -21,7 +21,10 @@ * pattern first, so it cannot carry grammar. The ID stays a variable. * - Moment is NOT a stamp: `Moment=` * selects the asset as it was then (as-of), and an update scoped by it - * lands on the current asset. A Moment guard guards nothing. + * lands on the current asset. A Moment guard guards nothing. Proven for + * writes on 2026-11-11: an update filtered on a stale Moment previews as + * one command and applies, overwriting the change made in between + * ("why not Moment, on a write"; versionone/VersionOne.SDK.JavaScript#51). * Every type has ChangeDate except the Ideas product's (Idea*), which keeps * no history and cannot be guarded. * diff --git a/test/integration/guard.test.ts b/test/integration/guard.test.ts index 148923b..1a39e55 100644 --- a/test/integration/guard.test.ts +++ b/test/integration/guard.test.ts @@ -8,7 +8,8 @@ import { describeInstance, fixtureName, instance } from "./instance"; * afterwards by exact OID): one changed by "someone else" between the * preview and the send, one not. What only the server can prove: that the * projection's add/remove form for a scalar applies in a real update, that - * a ChangeDate literal is a true compare-and-set (and a Moment is not), and + * a ChangeDate literal is a true compare-and-set (and a Moment is not, for a + * query or for a write), and * that a stale guard writes nothing. */ describeInstance("guard (live)", () => { @@ -59,4 +60,27 @@ describeInstance("guard (live)", () => { expect(asOf).toHaveLength(1); // stale, yet it matches — expect(asOf[0]!.Estimate).toBe("8"); // — as the asset was at that moment }); + + it("why not Moment, on a write: an update filtered on a stale Moment previews as one command and applies, while a stale ChangeDate literal applies nothing", async () => { + // Its own Story, so the result doesn't depend on what the tests above did to a and b. + const res = await assetApi.send(t, [{ AssetType: "Story", Name: fixtureName("guard moment write"), Scope: { _oid: "Scope:0" }, Estimate: 1 }], { throwOnFailure: true }); + const s = assetApi.momentless(res.assetsCreated.oidTokens[0]!); + created.push(s); + const [before] = await assetApi.query<{ _oid: string; Moment: string; ChangeDate: string }>(t, { from: s, select: ["Moment", "ChangeDate"], page: { size: 1, start: 0 } }); + await assetApi.send(t, [{ from: s, update: { Estimate: 2 } }], { throwOnFailure: true }); // "someone else" changes it + expect(await estimate(s)).toBe("2"); + + // Stale Moment, as a literal: the projection still has the asset, and the write lands on the current one. + const staleMoment = [{ from: "Story", filter: ["ID=$oid", `Moment='${before!.Moment}'`], with: { $oid: s }, update: { Estimate: 7 } }]; + expect(await assetApi.preview(t, staleMoment)).toHaveLength(1); + const sent = await assetApi.send(t, staleMoment); + expect(sent.assetsModified.oidTokens.map(assetApi.momentless)).toEqual([s]); + expect(await estimate(s)).toBe("7"); // the other writer's 2 is overwritten: Moment guards nothing + + // Stale ChangeDate, as a literal (what guard.ts sends): matches nothing, so nothing is applied. + const staleStamp = [{ from: "Story", filter: ["ID=$oid", `ChangeDate='${before!.ChangeDate}'`], with: { $oid: s }, update: { Estimate: 9 } }]; + expect(await assetApi.preview(t, staleStamp)).toEqual([]); + expect((await assetApi.send(t, staleStamp)).assetsModified.count).toBe(0); + expect(await estimate(s)).toBe("7"); + }); });