Skip to content

Commit 822aca5

Browse files
committed
wip: rescue in-progress work before container restart loss (envelope)
1 parent 4365ab2 commit 822aca5

3 files changed

Lines changed: 167 additions & 52 deletions

File tree

‎packages/metadata-protocol/src/protocol.record-not-found.test.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,33 @@ describe('[#4435] updateData refuses an id that names no row', () => {
9191
expect(res).toMatchObject({ object: 'task', id: 'real', record: { title: 'y' } });
9292
});
9393

94-
it('the existence probe is asked with the CALLER context, like getData', async () => {
95-
// A row outside the caller's row scope is not-found FOR THEM on the read
96-
// path; a write path that answered 200 for it would contradict the very
97-
// next GET.
94+
it('the existence probe asks EXISTENCE, not the caller\'s visibility', async () => {
95+
// Load-bearing, and the first cut of this fix got it backwards. Probing
96+
// with the CALLER's context turns the existence gate into an authorization
97+
// gate: a row the caller cannot read comes back null and the PATCH 404s.
98+
// That would (a) move an RLS decision out of the write policy where #1994
99+
// put it, and (b) disarm `@proof: rls-by-id-write` — the dogfood fixture
100+
// whose RED half asserts that a member who cannot READ a row but has no
101+
// write policy still mutates it by id. A caller-scoped probe makes that
102+
// proof go green, so the gate could no longer prove it can go red, and a
103+
// future revert of #1994 would be masked by this probe.
104+
//
105+
// So the probe runs as system: "does this row exist", nothing more.
106+
// Authorization stays inside engine.update, exactly where it was.
98107
const { p, findOne } = makeProtocol({ real: { id: 'real' } });
99-
const ctx = { userId: 'u1' };
100-
await p.updateData({ object: 'task', id: 'real', data: {}, context: ctx } as any);
101-
expect(findOne.mock.calls[0][1]).toMatchObject({ where: { id: 'real' }, context: ctx });
108+
await p.updateData({ object: 'task', id: 'real', data: {}, context: { userId: 'u1' } } as any);
109+
expect(findOne.mock.calls[0][1]).toMatchObject({
110+
where: { id: 'real' },
111+
context: { isSystem: true },
112+
});
113+
});
114+
115+
it('a PATCH the caller may not see is still decided by RLS, not by the probe', async () => {
116+
// The row exists, so the probe passes it through to the engine — where the
117+
// write policy answers, as it always has. The probe must not pre-empt it.
118+
const { p, update } = makeProtocol({ hidden: { id: 'hidden' } });
119+
await p.updateData({ object: 'task', id: 'hidden', data: { title: 'x' }, context: { userId: 'nobody' } } as any);
120+
expect(update).toHaveBeenCalledOnce();
102121
});
103122
});
104123

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4739,21 +4739,35 @@ export class ObjectStackProtocolImplementation implements
47394739

47404740
async updateData(request: { object: string, id: string, data: any, expectedVersion?: string, context?: any }) {
47414741
this.assertObjectRegistered(request.object); // [#3770]
4742-
await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
4743-
// [#4435] A PATCH of an id that names no row answered `200 { record:
4744-
// null }` — the caller had to null-check a SUCCESS payload to discover
4745-
// its write never landed, which is exactly what a client that PATCHes a
4746-
// concurrently deleted record does not do. `getData` has always answered
4747-
// 404 for the same id; the two verbs now agree.
4742+
// [#4435] ONE probe serves both gates.
47484743
//
4749-
// Checked BEFORE the write rather than by inspecting what comes back:
4750-
// the engine's update returns the post-write READBACK, which is also
4751-
// `null` when the row still exists but the write moved it out of the
4752-
// caller's row scope (reassigning `owner_id` away from yourself under an
4753-
// owner-scoped RLS policy). Reading that as "not found" would answer 404
4754-
// to a write that succeeded. Existence is the question, so ask it
4755-
// directly — the same `findOne` + context `getData` asks.
4756-
await this.assertRecordExists(request.object, request.id, request.context);
4744+
// A PATCH of an id that names no row answered `200 { record: null }` —
4745+
// the caller had to null-check a SUCCESS payload to discover its write
4746+
// never landed, which is exactly what a client that PATCHes a
4747+
// concurrently deleted record does not do. `getData` has always
4748+
// answered 404 for the same id; the two verbs now agree.
4749+
//
4750+
// Existence is asked BEFORE the write rather than read off what comes
4751+
// back: the engine's update returns the post-write READBACK, which is
4752+
// also `null` when the row still exists but the write moved it out of
4753+
// the caller's row scope (reassigning `owner_id` away from yourself
4754+
// under an owner-scoped RLS policy). Reading that as "not found" would
4755+
// answer 404 to a write that succeeded. So ask existence directly.
4756+
//
4757+
// The probe asks EXISTENCE, not visibility — see `probeRecord` for why
4758+
// that distinction is load-bearing (it keeps this fix out of the RLS
4759+
// model and keeps the #1994 by-id-write proof able to go red).
4760+
//
4761+
// OCC already had to read the same row for its `updated_at`, so the two
4762+
// gates share this single read instead of issuing a probe each. Two
4763+
// round-trips per PATCH would have been a performance regression no
4764+
// gate reports, and the second read could even disagree with the first.
4765+
const current = await this.probeRecord(request.object, request.id);
4766+
if (!current) throw recordNotFoundError(request.object, request.id);
4767+
// 404 wins over 409 when both could apply: OCC has always declined to
4768+
// treat a missing record as a concurrency conflict, and "this record
4769+
// does not exist" is the more specific answer.
4770+
this.assertVersionOf(request.object, request.id, current, request.expectedVersion);
47574771
const opts: any = { where: { id: request.id } };
47584772
if (request.context !== undefined) opts.context = request.context;
47594773
// [#3407/#3431] Capture the engine's LEGAL write strips (static `readonly`
@@ -4775,7 +4789,7 @@ export class ObjectStackProtocolImplementation implements
47754789

47764790
async deleteData(request: { object: string, id: string, expectedVersion?: string, context?: any }) {
47774791
this.assertObjectRegistered(request.object); // [#3770]
4778-
await this.assertVersionMatch(request.object, request.id, request.expectedVersion, request.context);
4792+
await this.assertVersionMatch(request.object, request.id, request.expectedVersion);
47794793
const opts: any = { where: { id: request.id } };
47804794
if (request.context !== undefined) opts.context = request.context;
47814795
const deleted = await this.engine.delete(request.object, opts);
@@ -4801,50 +4815,72 @@ export class ObjectStackProtocolImplementation implements
48014815
}
48024816

48034817
/**
4804-
* [#4435] Throw `404 RECORD_NOT_FOUND` when `id` names no row VISIBLE to
4805-
* this caller — the same question, asked the same way, that `getData`
4806-
* answers with. Threading `context` is what makes "visible to this caller"
4807-
* true: a row the caller's RLS scope excludes is not-found for them on the
4808-
* read path, and a write path that disagreed would answer 200 for a record
4809-
* the very next GET reports as 404.
4818+
* [#4435] Does this row EXIST? A fact about the database — deliberately
4819+
* NOT "may this caller see it".
4820+
*
4821+
* Read with a system context so no row-level policy narrows it. That is
4822+
* load-bearing, and the first version of this fix got it wrong: probing
4823+
* with the CALLER's context turns the existence gate into an authorization
4824+
* gate, because a row the caller cannot read comes back `null` and the
4825+
* PATCH answers 404. Two things break when it does.
4826+
*
4827+
* 1. It silently changes RLS semantics. Whether an unreadable row may be
4828+
* written by id is the #1994 pre-image check's decision, made inside
4829+
* `engine.update` where the write policy lives. A probe in front of it
4830+
* quietly adds a second, different rule — scope creep into the security
4831+
* model, from a bug fix about missing records.
4832+
*
4833+
* 2. It disarms a revert-provable security proof. `@proof: rls-by-id-write`
4834+
* (`packages/qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by
4835+
* the `permission.rowLevelSecurity.using` liveness ledger entry) boots a
4836+
* fixture whose member can read nothing but has no write policy, and
4837+
* asserts the runner reports `rls-hole`. A caller-scoped probe 404s that
4838+
* PATCH, so the RED half goes green — and the gate can no longer prove
4839+
* it is able to go red. If the #1994 fix were ever reverted, this probe
4840+
* would MASK it. Accidentally hardening one path is not worth
4841+
* permanently blinding the gate that watches the whole class.
4842+
*
4843+
* So the probe answers existence only, and authorization stays exactly
4844+
* where it was. The one behaviour this adds is the 404 the issue asked for:
4845+
* an id that names no row at all.
4846+
*
4847+
* One place, because two gates need this row — the existence gate and OCC's
4848+
* `updated_at` comparison — and issuing a probe each would put two
4849+
* round-trips on every PATCH.
48104850
*/
4811-
private async assertRecordExists(object: string, id: string, context: any): Promise<void> {
4812-
const findOpts: any = { where: { id } };
4813-
if (context !== undefined) findOpts.context = context;
4814-
const existing = await this.engine.findOne(object, findOpts);
4815-
if (!existing) throw recordNotFoundError(object, id);
4851+
private async probeRecord(object: string, id: string): Promise<any> {
4852+
return this.engine.findOne(object, { where: { id }, context: { isSystem: true } } as any);
48164853
}
48174854

48184855
/**
4819-
* Optimistic Concurrency Control gate shared by updateData/deleteData.
4856+
* Optimistic Concurrency Control — the COMPARISON half, over a row the
4857+
* caller has already read. Pure: it issues no query of its own, which is
4858+
* what lets `updateData` run the gate on its existence probe's result
4859+
* rather than re-reading the record (#4435).
48204860
*
4821-
* When the caller passes a non-empty `expectedVersion` token (typically
4822-
* the `updated_at` value they read), this fetches the current record
4823-
* and compares its `updated_at` against the token. Mismatch → throw
4824-
* `ConcurrentUpdateError` which the REST layer maps to 409.
4861+
* When the caller passes a non-empty `expectedVersion` token (typically the
4862+
* `updated_at` value they read), a mismatch throws `ConcurrentUpdateError`,
4863+
* which the REST layer maps to 409.
48254864
*
48264865
* Behaviour:
48274866
* - Empty/missing token → no check (opt-in semantics; existing callers
48284867
* that haven't yet adopted OCC are unaffected).
4829-
* - Record not found → no check; downstream `engine.update` will
4830-
* surface the usual `RECORD_NOT_FOUND` 404. We intentionally do not
4831-
* treat "missing record" as a concurrency conflict.
4868+
* - Record not found → no check. We intentionally do not treat "missing
4869+
* record" as a concurrency conflict; `updateData` has already answered
4870+
* 404 by this point, and `deleteData` lets the driver report it.
48324871
* - Record has no `updated_at` field (timestamps disabled) → no check.
48334872
* Logging would be noisy here; OCC is opt-in and the absence of a
48344873
* version column is an explicit "this object doesn't support OCC"
48354874
* signal.
48364875
*/
4837-
private async assertVersionMatch(
4876+
private assertVersionOf(
48384877
object: string,
48394878
id: string,
4879+
current: any,
48404880
expectedVersion: string | undefined,
4841-
context: any
4842-
): Promise<void> {
4881+
): void {
48434882
const expected = normaliseVersionToken(expectedVersion);
48444883
if (!expected) return;
4845-
const findOpts: any = { where: { id } };
4846-
if (context !== undefined) findOpts.context = context;
4847-
const current = await this.engine.findOne(object, findOpts);
48484884
if (!current) return;
48494885
const currentVersion = normaliseVersionToken((current as any).updated_at);
48504886
if (!currentVersion) return;
@@ -4857,6 +4893,22 @@ export class ObjectStackProtocolImplementation implements
48574893
}
48584894
}
48594895

4896+
/**
4897+
* OCC gate for `deleteData`, which — unlike `updateData` — needs no
4898+
* existence probe of its own: the driver's own `delete` return reports
4899+
* whether a row matched (#4435). So this still probes ONLY when the caller
4900+
* actually opted into OCC, keeping a plain DELETE at zero extra reads.
4901+
*/
4902+
private async assertVersionMatch(
4903+
object: string,
4904+
id: string,
4905+
expectedVersion: string | undefined,
4906+
): Promise<void> {
4907+
if (!normaliseVersionToken(expectedVersion)) return;
4908+
const current = await this.probeRecord(object, id);
4909+
this.assertVersionOf(object, id, current, expectedVersion);
4910+
}
4911+
48604912
// ==========================================
48614913
// Global Search (M10.5)
48624914
// ==========================================

‎packages/objectql/src/protocol-data.test.ts‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -403,16 +403,26 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
403403
// ═══════════════════════════════════════════════════════════════
404404
describe('Optimistic Concurrency Control', () => {
405405
beforeEach(() => {
406-
// Both update and delete need `update` / `delete` on the
407-
// engine, plus `findOne` for the version probe.
406+
// Both update and delete need `update` / `delete` on the engine,
407+
// plus `findOne` for the record probe.
408+
//
409+
// [#4435] The record now has to EXIST for a PATCH to proceed at
410+
// all: `updateData` refuses an id that names no row instead of
411+
// answering `200 { record: null }`. The default probe result is
412+
// therefore a real row — these cases are about OCC, not about
413+
// missing records (which `updateData refuses an id …` covers).
414+
mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' });
408415
mockEngine.update = vi.fn().mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:33.000Z' });
409416
mockEngine.delete = vi.fn().mockResolvedValue(true);
410417
});
411418

412419
it('updateData proceeds when no expectedVersion is supplied (legacy callers)', async () => {
413420
await protocol.updateData({ object: 'task', id: 'r1', data: { name: 'New' } });
414-
// No version probe was issued
415-
expect(mockEngine.findOne).not.toHaveBeenCalled();
421+
// [#4435] One probe — the EXISTENCE probe, which every PATCH now
422+
// makes. No OCC comparison happens (no token was supplied), which
423+
// is what "legacy callers are unaffected" means: they are not
424+
// subject to 409, only to the 404 that GET already answered.
425+
expect(mockEngine.findOne).toHaveBeenCalledOnce();
416426
expect(mockEngine.update).toHaveBeenCalledOnce();
417427
});
418428

@@ -424,10 +434,31 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
424434
data: { name: 'New' },
425435
expectedVersion: '2026-05-22T07:14:00.000Z',
426436
});
437+
// [#4435] Still exactly ONE read. The existence gate and OCC both
438+
// need this row, so they share the probe rather than issuing one
439+
// each — two round-trips per PATCH would be a performance
440+
// regression no gate reports, and the second read could disagree
441+
// with the first.
427442
expect(mockEngine.findOne).toHaveBeenCalledOnce();
428443
expect(mockEngine.update).toHaveBeenCalledOnce();
429444
});
430445

446+
it('updateData refuses an id that names no row, before any OCC verdict', async () => {
447+
// [#4435] 404 wins over 409 when both could apply: OCC has always
448+
// declined to treat a missing record as a concurrency conflict, and
449+
// "this record does not exist" is the more specific answer.
450+
mockEngine.findOne.mockResolvedValue(null);
451+
await expect(
452+
protocol.updateData({
453+
object: 'task',
454+
id: 'gone',
455+
data: { name: 'New' },
456+
expectedVersion: '2026-05-22T07:14:00.000Z',
457+
})
458+
).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 });
459+
expect(mockEngine.update).not.toHaveBeenCalled();
460+
});
461+
431462
it('updateData strips RFC-7232 quotes from the If-Match token', async () => {
432463
mockEngine.findOne.mockResolvedValue({ id: 'r1', updated_at: '2026-05-22T07:14:00.000Z' });
433464
await protocol.updateData({
@@ -475,16 +506,29 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => {
475506
});
476507

477508
it('updateData skips the check when expectedVersion is empty string', async () => {
509+
// A blank token opts OUT of OCC — the record still has to exist
510+
// (#4435), so the probe happens; what must NOT happen is a 409.
478511
await protocol.updateData({
479512
object: 'task',
480513
id: 'r1',
481514
data: { name: 'New' },
482515
expectedVersion: ' ',
483516
});
484-
expect(mockEngine.findOne).not.toHaveBeenCalled();
517+
expect(mockEngine.findOne).toHaveBeenCalledOnce();
485518
expect(mockEngine.update).toHaveBeenCalledOnce();
486519
});
487520

521+
it('deleteData without a token issues NO probe at all', async () => {
522+
// [#4435] DELETE needs no existence probe: the driver's own return
523+
// ("True if deleted, false if not found") reports whether a row
524+
// matched, so a plain DELETE stays at zero extra reads and only an
525+
// OCC token buys one.
526+
mockEngine.findOne.mockClear();
527+
await protocol.deleteData({ object: 'task', id: 'r1' });
528+
expect(mockEngine.findOne).not.toHaveBeenCalled();
529+
expect(mockEngine.delete).toHaveBeenCalledOnce();
530+
});
531+
488532
it('deleteData throws ConcurrentUpdateError on version mismatch', async () => {
489533
mockEngine.findOne.mockResolvedValue({
490534
id: 'r1',

0 commit comments

Comments
 (0)