Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/services/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
* pattern first, so it cannot carry grammar. The ID stays a variable.
* - Moment is NOT a stamp: `Moment=<an earlier moment of this asset>`
* 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.
*
Expand Down
26 changes: 25 additions & 1 deletion test/integration/guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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");
});
});
Loading