Skip to content
Open
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
34 changes: 34 additions & 0 deletions .changeset/update-record-dropped-field-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/service-automation": minor
---

feat(automation): surface silently-stripped write fields as step warnings (#3407)

`update_record` used to report an unconditional `success` even when the data
layer legally stripped the requested write fields — static `readonly` (#2948)
or a TRUE `readonlyWhen` predicate (#3042). The only trace was a server-side
logger warn, invisible in the flow run trace: an author saw a clean 3ms
`success` while the DB truth never changed (how #3356's approval stage
write-backs failed unnoticed).

- **spec**: new `DroppedFieldsEventSchema` / `DroppedFieldsEvent`
(`{ object, fields, reason: 'readonly' | 'readonly_when' }`) in
`data/data-engine.zod.ts`, and a `WriteObservabilityOptions`
(`onFieldsDropped` listener) mixin on `IDataEngine.insert/update` option
params in `contracts/data-engine.ts`. The listener is a TS-contract-level,
in-process-only channel — deliberately NOT part of the serializable Zod
options schemas or the RPC boundary.
- **objectql**: `engine.update()` reports each strip pass's dropped keys +
reason through `options.onFieldsDropped` (all four strip sites: single-id +
bulk × readonly + readonlyWhen). A throwing listener never breaks the write.
System-context writes skip the readonly strip and therefore report nothing,
as before. `insert()` accepts the option for symmetry but strips nothing
today (INSERT is readonly-exempt; FLS write denial throws).
- **service-automation**: `NodeExecutionResult` and `StepLogEntry` gain
advisory `warnings?: string[]`; `update_record` / `create_record` attach one
warning per strip event naming the dropped fields, plus a structured
`droppedFields` output (`{<nodeId>.droppedFields}`) for downstream nodes.
`success` semantics are unchanged — stripping stays legal, it just is no
longer silent.
36 changes: 33 additions & 3 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ export interface IDataEngine {
count(objectName: string, query?: EngineCountOptions): Promise<number>;
aggregate(objectName: string, query: EngineAggregateOptions): Promise<any[]>;

// Mutation
insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions): Promise<any>;
update(objectName: string, data: any, options?: EngineUpdateOptions): Promise<any>;
// Mutation (write ops also accept in-process WriteObservabilityOptions — see `update`)
insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise<any>;
update(objectName: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise<any>;
delete(objectName: string, options?: EngineDeleteOptions): Promise<any>;

// AI / Vector Search (optional)
Expand Down Expand Up @@ -222,6 +222,36 @@ interface EngineUpdateOptions {
}
```

### WriteObservabilityOptions

The write methods (`insert` / `update`) additionally accept an **in-process**
`onFieldsDropped` listener. The engine invokes it when caller-supplied write
fields are legally stripped from the payload before the driver write — static
`readonly` fields or a TRUE `readonlyWhen` predicate. The write still succeeds;
the listener exists so callers that report per-field success (e.g. the flow
engine's `update_record` step) can surface a warning instead of a silent
success.

```typescript
interface WriteObservabilityOptions {
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
}

interface DroppedFieldsEvent {
object: string; // resolved object name
fields: string[]; // caller-supplied fields that were dropped
reason: 'readonly' | 'readonly_when'; // why they were dropped
}
```

<Callout type="warn">
`onFieldsDropped` is a **TS-contract-level, in-process-only** channel. It is
deliberately not part of the serializable Zod options schemas: a function is
unrepresentable in JSON Schema and cannot cross the RPC (Virtual Data Engine)
boundary, so remote callers never receive these events. A listener that throws
never breaks the write — the engine catches and logs.
</Callout>

### delete

Deletes record(s) matching the `where` condition.
Expand Down
19 changes: 17 additions & 2 deletions content/docs/references/data/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ The Data Engine acts as the "Driver" layer in the Hexagonal Architecture.
## TypeScript Usage

```typescript
import { BaseEngineOptions, DataEngineAggregateOptions, DataEngineAggregateRequest, DataEngineBatchRequest, DataEngineCountOptions, DataEngineCountRequest, DataEngineDeleteOptions, DataEngineDeleteRequest, DataEngineExecuteRequest, DataEngineFilter, DataEngineFindOneRequest, DataEngineFindRequest, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineUpdateRequest, DataEngineVectorFindRequest, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data';
import type { BaseEngineOptions, DataEngineAggregateOptions, DataEngineAggregateRequest, DataEngineBatchRequest, DataEngineCountOptions, DataEngineCountRequest, DataEngineDeleteOptions, DataEngineDeleteRequest, DataEngineExecuteRequest, DataEngineFilter, DataEngineFindOneRequest, DataEngineFindRequest, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineUpdateRequest, DataEngineVectorFindRequest, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data';
import { BaseEngineOptions, DataEngineAggregateOptions, DataEngineAggregateRequest, DataEngineBatchRequest, DataEngineCountOptions, DataEngineCountRequest, DataEngineDeleteOptions, DataEngineDeleteRequest, DataEngineExecuteRequest, DataEngineFilter, DataEngineFindOneRequest, DataEngineFindRequest, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineUpdateRequest, DataEngineVectorFindRequest, DroppedFieldsEvent, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data';
import type { BaseEngineOptions, DataEngineAggregateOptions, DataEngineAggregateRequest, DataEngineBatchRequest, DataEngineCountOptions, DataEngineCountRequest, DataEngineDeleteOptions, DataEngineDeleteRequest, DataEngineExecuteRequest, DataEngineFilter, DataEngineFindOneRequest, DataEngineFindRequest, DataEngineInsertOptions, DataEngineInsertRequest, DataEngineQueryOptions, DataEngineRequest, DataEngineSort, DataEngineUpdateOptions, DataEngineUpdateRequest, DataEngineVectorFindRequest, DroppedFieldsEvent, EngineAggregateOptions, EngineCountOptions, EngineDeleteOptions, EngineQueryOptions, EngineUpdateOptions } from '@objectstack/spec/data';

// Validate data
const result = BaseEngineOptions.parse(data);
Expand Down Expand Up @@ -467,6 +467,21 @@ Options for DataEngine.update operations
| **threshold** | `number` | optional | |


---

## DroppedFieldsEvent

A write-path strip event: caller-supplied fields legally dropped from the payload (#3407)

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object the write targeted (resolved object name) |
| **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload |
| **reason** | `Enum<'readonly' \| 'readonly_when'>` | ✅ | Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |


---

## EngineAggregateOptions
Expand Down
34 changes: 22 additions & 12 deletions packages/core/src/contracts/data-engine.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,50 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import {
import {
EngineQueryOptions,
DataEngineInsertOptions,
EngineUpdateOptions,
DataEngineInsertOptions,
EngineUpdateOptions,
EngineDeleteOptions,
EngineAggregateOptions,
EngineAggregateOptions,
EngineCountOptions,
DataEngineRequest,
DroppedFieldsEvent,
} from '@objectstack/spec/data';

/**
* In-process write-observability hooks for `insert`/`update` (#3407).
* Mirror of `WriteObservabilityOptions` in `@objectstack/spec/contracts` —
* see that definition for the full rationale (in-process only; never part of
* the serializable options schemas or the RPC boundary).
*/
export interface WriteObservabilityOptions {
/** Called once per strip pass that dropped ≥1 caller-supplied field. */
onFieldsDropped?: (event: DroppedFieldsEvent) => void;
}

/**
* IDataEngine - Standard Data Engine Interface
*
*
* Abstract interface for data persistence capabilities.
* Following the Dependency Inversion Principle - plugins depend on this interface,
* not on concrete database implementations.
*
*
* All query methods use standard QueryAST parameter names
* (where/fields/orderBy/limit/offset/expand) to eliminate mechanical translation
* between the Engine and Driver layers.
*
*
* Aligned with 'src/data/data-engine.zod.ts' in @objectstack/spec.
*/

export interface IDataEngine {
find(objectName: string, query?: EngineQueryOptions): Promise<any[]>;
findOne(objectName: string, query?: EngineQueryOptions): Promise<any>;
insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions): Promise<any>;
update(objectName: string, data: any, options?: EngineUpdateOptions): Promise<any>;
insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise<any>;
update(objectName: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise<any>;
delete(objectName: string, options?: EngineDeleteOptions): Promise<any>;
count(objectName: string, query?: EngineCountOptions): Promise<number>;
aggregate(objectName: string, query: EngineAggregateOptions): Promise<any[]>;

/**
* Vector Search (AI/RAG)
*/
Expand All @@ -48,5 +60,3 @@ export interface IDataEngine {
*/
execute?(command: any, options?: Record<string, any>): Promise<any>;
}


106 changes: 106 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,112 @@ describe('ObjectQL Engine', () => {
});
});

/**
* #3407 — the readonly/readonlyWhen strips are legal semantics, but they
* must not be SILENT to the caller: `options.onFieldsDropped` reports each
* strip pass's dropped keys + reason so callers (flow `update_record`
* steps) can surface a warning on an otherwise-successful write.
*/
describe('Dropped-field write observability (#3407)', () => {
beforeEach(async () => {
engine.registerDriver(mockDriver, true);
await engine.init();
(mockDriver as any).updateMany = vi.fn().mockResolvedValue(1);
});

const docSchema = {
name: 'doc',
fields: {
title: { type: 'text' },
created_by: { type: 'text', readonly: true },
},
} as any;

it('reports caller-supplied static-readonly fields stripped on a single-id update', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue(docSchema);
const events: any[] = [];
await engine.update('doc', { id: '1', title: 'x', created_by: 'attacker' }, {
onFieldsDropped: (e: any) => events.push(e),
} as any);

expect(events).toEqual([{ object: 'doc', fields: ['created_by'], reason: 'readonly' }]);
// The strip behavior itself is unchanged: the write proceeded without the field.
const [, , data] = vi.mocked(mockDriver.update).mock.calls[0];
expect(data).not.toHaveProperty('created_by');
expect(data).toHaveProperty('title', 'x');
});

it('reports a readonlyWhen-locked field on a single-id update (reason readonly_when)', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'invoice',
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' } },
} as any);
vi.mocked(mockDriver.findOne).mockResolvedValue({ id: '1', locked: true, amount: 100 } as any);

const events: any[] = [];
await engine.update('invoice', { id: '1', amount: 999 }, {
onFieldsDropped: (e: any) => events.push(e),
} as any);

expect(events).toEqual([{ object: 'invoice', fields: ['amount'], reason: 'readonly_when' }]);
const [, , data] = vi.mocked(mockDriver.update).mock.calls[0];
expect(data).not.toHaveProperty('amount');
});

it('reports bulk-path strips too (multi update — locked in ≥1 matched row)', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue({
name: 'invoice',
fields: { amount: { type: 'number', readonlyWhen: 'record.locked == true' } },
} as any);
vi.mocked(mockDriver.find).mockResolvedValue([
{ id: 'a', locked: false, amount: 10 },
{ id: 'b', locked: true, amount: 20 },
] as any);

const events: any[] = [];
await engine.update('invoice', { amount: 999 }, {
where: { status: 'draft' }, multi: true,
onFieldsDropped: (e: any) => events.push(e),
} as any);

expect(events).toEqual([{ object: 'invoice', fields: ['amount'], reason: 'readonly_when' }]);
const [, , data] = (mockDriver as any).updateMany.mock.calls[0];
expect(data).not.toHaveProperty('amount');
});

it('does NOT report for a system-context update (the readonly strip is skipped)', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue(docSchema);
const events: any[] = [];
await engine.update('doc', { id: '1', created_by: 'importer' }, {
context: { isSystem: true },
onFieldsDropped: (e: any) => events.push(e),
} as any);
expect(events).toEqual([]);
// System writes legitimately set read-only columns — kept, not stripped.
const [, , data] = vi.mocked(mockDriver.update).mock.calls[0];
expect(data).toHaveProperty('created_by', 'importer');
});

it('does NOT report when nothing was stripped', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue(docSchema);
const events: any[] = [];
await engine.update('doc', { id: '1', title: 'clean' }, {
onFieldsDropped: (e: any) => events.push(e),
} as any);
expect(events).toEqual([]);
});

it('a throwing listener never breaks the write', async () => {
vi.mocked(SchemaRegistry.getObject).mockReturnValue(docSchema);
await expect(
engine.update('doc', { id: '1', created_by: 'x' }, {
onFieldsDropped: () => { throw new Error('listener bug'); },
} as any),
).resolves.not.toThrow();
expect(vi.mocked(mockDriver.update)).toHaveBeenCalledTimes(1);
});
});

describe('Expand Related Records', () => {
beforeEach(async () => {
engine.registerDriver(mockDriver, true);
Expand Down
Loading