From 83022467560ca68dfe9fd2d7d27a26d99239596d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:53:13 +0800 Subject: [PATCH 1/3] feat(rest/protocol): extend droppedFields write-observability to bulk paths + client SDK (#3455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3448 (#3431 D2). Single-write PATCH/POST /data already surfaces LEGALLY-stripped write fields (readonly #2948 / readonlyWhen #3042 / #3043 create ingress) as `droppedFields`; the bulk paths dropped them silently. This closes out the deferred波及面. - metadata-protocol: updateManyData + batchData collect per-row onFieldsDropped and attach to each result row; insertManyData attaches per-row via ingress diff; createManyData returns an aggregated top-level droppedFields (no per-row slot; static-readonly strip is schema-uniform). Correctness fix: updateManyData and batchData never threaded the caller `context` — bulk writes ran context-less (RLS/FLS/readonlyWhen without the principal; batch create strip forced non-system). All engine calls now run under the resolved context. - spec: BatchOperationResultSchema gains optional per-row droppedFields (covers updateMany + batch); CreateManyDataResponseSchema gains the aggregated one. Both omit-when-empty. No X-ObjectStack-Dropped-Fields header for batches by design — one header cannot express per-row drops, so the body is the canonical channel. - client: CreateDataResult / UpdateDataResult gain droppedFields?: DroppedFieldsEvent[]. - hono adapter + plugin-hono-server: x-objectstack-dropped-fields added to the default Access-Control-Expose-Headers (lockstep across both CORS sites). - Design decision: kept precise droppedFields (reused DroppedFieldsEventSchema) over a generic warnings envelope, for symmetry with the shipped single-write path. - GraphQL item is a no-op: GraphQL has no runtime (kernel.graphql unassigned, handleGraphQL 501s, discovery never advertises it) — nothing to wire until an engine lands, at which point the protocol-layer droppedFields is already present. Tests: 10 bulk protocol cases (per-row + aggregated + context threading + returnRecords=false) and a CORS default-expose assertion. Co-Authored-By: Claude Fable 5 --- .../dropped-fields-bulk-graphql-client.md | 57 +++++ packages/adapters/hono/src/hono.test.ts | 13 ++ packages/adapters/hono/src/index.ts | 5 +- packages/client/src/index.ts | 17 +- .../src/protocol.dropped-fields.bulk.test.ts | 199 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 135 +++++++++--- .../plugins/plugin-hono-server/src/adapter.ts | 10 +- .../plugin-hono-server/src/hono-plugin.ts | 6 +- packages/spec/src/api/batch.zod.ts | 11 + packages/spec/src/api/protocol.zod.ts | 10 + 10 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 .changeset/dropped-fields-bulk-graphql-client.md create mode 100644 packages/metadata-protocol/src/protocol.dropped-fields.bulk.test.ts diff --git a/.changeset/dropped-fields-bulk-graphql-client.md b/.changeset/dropped-fields-bulk-graphql-client.md new file mode 100644 index 0000000000..e46d315ed9 --- /dev/null +++ b/.changeset/dropped-fields-bulk-graphql-client.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/client": minor +"@objectstack/hono": patch +"@objectstack/plugin-hono-server": patch +--- + +feat(rest/protocol): extend droppedFields write-observability to the bulk paths + client SDK (#3455) + +Follow-up to #3448 (#3431 D2): the single-write PATCH/POST `/data` paths already +surface LEGALLY-stripped write fields (static `readonly` #2948 / `readonlyWhen` +#3042 / #3043 create ingress) as `droppedFields`. The **bulk** write paths did +not — the same strips happened silently on every batched row — and the typed +client warning + CORS mirror were deferred. This closes those out. + +**Bulk passthrough (metadata-protocol).** +- `updateManyData` and `batchData` (update/upsert rows) now register a per-row + `onFieldsDropped` collector and attach the events to that row's result. +- `createManyData` diffs each supplied row against its #3043-stripped form and + returns an **aggregated** top-level `droppedFields` (one event per + object/reason with the union of field names) — its `{ records, count }` + response has no per-row slot, and the insert-time strip is static-`readonly` + only, so it is schema-uniform across rows and the aggregate is faithful. +- `insertManyData` keeps per-row precision, attaching `droppedFields` to each + outcome. +- **Correctness fix bundled in:** `updateManyData` and `batchData` never threaded + the caller's execution `context` to the engine — bulk writes ran context-less, + so RLS/FLS and `readonlyWhen` evaluated without the caller's principal, and the + batch create-ingress strip was hard-coded to a non-system context. All engine + calls in both methods now run under the resolved `context`. + +**Contract (spec).** `BatchOperationResultSchema` gains an optional per-row +`droppedFields` (covers `updateMany` + `batch`, which alias +`BatchUpdateResponseSchema`); `CreateManyDataResponseSchema` gains the optional +aggregated `droppedFields`. Both are omit-when-empty, so existing clients are +unaffected. `X-ObjectStack-Dropped-Fields` is deliberately **not** emitted for +batches — one response header cannot express per-row drops, so the per-row body +field is the canonical bulk channel. + +**Typed client warnings (@objectstack/client).** `CreateDataResult` / +`UpdateDataResult` gain `droppedFields?: DroppedFieldsEvent[]`, giving the body +channel a type instead of an untyped property. + +**CORS (@objectstack/hono, @objectstack/plugin-hono-server).** +`x-objectstack-dropped-fields` is added to the default `Access-Control-Expose-Headers` +allow-list (kept in lockstep across both Hono CORS sites) so a cross-origin +browser can read the single-write drop header. The body `droppedFields` remains +the primary, cross-origin-safe surface — this is a convenience mirror. + +**GraphQL — not applicable (documented).** #3455 lists a GraphQL mutation item, +but GraphQL has no runtime: `kernel.graphql` is unassigned everywhere and +`handleGraphQL` returns `501`, and discovery never advertises `/graphql`. There +is no schema generator or mutation resolver to expose a typed payload field on, +so there is nothing to wire until a GraphQL engine lands — at which point the +protocol-layer `droppedFields` is already present and only the GraphQL schema +projection would remain. diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index f021e6020d..5be983e217 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -1053,6 +1053,19 @@ describe('createHonoApp', () => { expect(exposed.toLowerCase()).toContain('set-auth-token'); }); + // [#3455] The single-write `X-ObjectStack-Dropped-Fields` warning header must + // be readable cross-origin; kept in lockstep with plugin-hono-server's default. + it('always exposes x-objectstack-dropped-fields by default', async () => { + const app = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' }); + + const res = await app.request('/api/v1/meta', { + method: 'GET', + headers: { Origin: 'https://app.example.com' }, + }); + const exposed = (res.headers.get('access-control-expose-headers') || '').toLowerCase(); + expect(exposed).toContain('x-objectstack-dropped-fields'); + }); + it('merges user-supplied exposeHeaders with set-auth-token (does not replace)', async () => { const app = createHonoApp({ kernel: mockKernel, diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 920109559f..ee1461746a 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -177,7 +177,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // // This mirrors `plugin-hono-server`'s CORS wiring — all three // Hono-based CORS sites must stay in lockstep on this default. - const defaultExposeHeaders = ['set-auth-token']; + // `x-objectstack-dropped-fields` (#3455) lets a cross-origin browser read + // the single-write drop header (#3431); the body `droppedFields` channel is + // the primary, cross-origin-safe surface, so this is a convenience mirror. + const defaultExposeHeaders = ['set-auth-token', 'x-objectstack-dropped-fields']; const exposeHeaders = Array.from(new Set([ ...defaultExposeHeaders, ...(corsOpts.exposeHeaders ?? []), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 335b3d0a2f..62b88672fc 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { QueryAST, SortNode, AggregationNode, isFilterAST } from '@objectstack/spec/data'; +import { QueryAST, SortNode, AggregationNode, isFilterAST, type DroppedFieldsEvent } from '@objectstack/spec/data'; import { BatchUpdateRequest, BatchUpdateResponse, @@ -218,6 +218,14 @@ export interface CreateDataResult { object: string; id: string; record: T; + /** + * [#3431/#3455] Caller-supplied fields the server LEGALLY stripped before the + * record was written — e.g. a non-system create cannot seed a static `readonly` + * column (#3043), so those keys are dropped and the field re-derives its default. + * Present only when ≥1 field was dropped; the create still succeeded. REST also + * mirrors this in the `X-ObjectStack-Dropped-Fields` response header. + */ + droppedFields?: DroppedFieldsEvent[]; } /** Spec: UpdateDataResponseSchema */ @@ -225,6 +233,13 @@ export interface UpdateDataResult { object: string; id: string; record: T; + /** + * [#3431/#3455] Caller-supplied fields the server LEGALLY stripped from the + * write before persisting — static `readonly` (#2948) or a TRUE `readonlyWhen` + * predicate (#3042). Present only when ≥1 field was dropped; the update still + * succeeded. REST also mirrors this in the `X-ObjectStack-Dropped-Fields` header. + */ + droppedFields?: DroppedFieldsEvent[]; } /** Spec: DeleteDataResponseSchema */ diff --git a/packages/metadata-protocol/src/protocol.dropped-fields.bulk.test.ts b/packages/metadata-protocol/src/protocol.dropped-fields.bulk.test.ts new file mode 100644 index 0000000000..00f2289598 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.dropped-fields.bulk.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#3455] Extends the single-write drop-observability of #3431 to the BULK +// write paths. Each bulk method must (a) surface the same LEGAL strips +// (static `readonly` #2948 / `readonlyWhen` #3042 / #3043 create ingress) that +// single-write now reports, and (b) thread the caller's execution `context` to +// the engine so RLS/FLS/`readonlyWhen` run under the caller — a gap the +// pre-#3455 `updateManyData`/`batchData` loops had. Channels: +// - updateManyData / batchData → per-row `droppedFields` on each result row; +// - insertManyData → per-row `droppedFields` on each outcome; +// - createManyData → aggregated top-level `droppedFields` (its +// response has no per-row slot; the insert strip is schema-uniform). + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { + name: 'approval_case', + fields: { + title: { name: 'title', type: 'text' }, + approval_status: { name: 'approval_status', type: 'text', readonly: true, defaultValue: 'draft' }, + }, +}; + +describe('updateManyData — per-row droppedFields + context threading (#3455)', () => { + it('surfaces per-row engine strips and threads the caller context to each update', async () => { + const update = vi.fn(async (object: string, data: any, options?: any) => { + // Only the second row forges the readonly field → only it drops. + if (data.approval_status !== undefined) { + options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); + } + return { id: options.where.id, title: data.title }; + }); + const engine = { registry: { getObject: () => SCHEMA }, update, findOne: vi.fn(async () => null) }; + const p = new ObjectStackProtocolImplementation(engine as any); + + const ctx = { userId: 'u1' }; + const res: any = await p.updateManyData({ + object: 'approval_case', + records: [ + { id: 'rec-1', data: { title: 'A' } }, + { id: 'rec-2', data: { title: 'B', approval_status: 'approved' } }, + ], + context: ctx, + } as any); + + // Row 0 dropped nothing → no droppedFields key; row 1 dropped approval_status. + expect(res.results[0]).not.toHaveProperty('droppedFields'); + expect(res.results[1].droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + // [#3455] The pre-fix loop never threaded context — assert every engine + // call now runs under the caller's principal. + expect(update).toHaveBeenCalledTimes(2); + expect(update.mock.calls[0][2].context).toBe(ctx); + expect(update.mock.calls[1][2].context).toBe(ctx); + expect(res.succeeded).toBe(2); + }); +}); + +describe('createManyData — aggregated top-level droppedFields (#3455)', () => { + function makeProtocol() { + const engine = { + registry: { getObject: (n: string) => (n === 'approval_case' ? SCHEMA : undefined) }, + insert: vi.fn(async (_object: string, rows: any[]) => rows.map((r, i) => ({ id: `rec-${i + 1}`, ...r }))), + }; + return { p: new ObjectStackProtocolImplementation(engine as any), engine }; + } + + it('aggregates the schema-uniform ingress strip across rows into one event', async () => { + const { p } = makeProtocol(); + const res: any = await p.createManyData({ + object: 'approval_case', + records: [ + { title: 'A', approval_status: 'approved' }, + { title: 'B', approval_status: 'approved' }, + ], + context: { userId: 'u1' }, + }); + // Union, not one-event-per-row: both rows dropped the same readonly field. + expect(res.droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + expect(res.count).toBe(2); + expect(res.records[0]).not.toHaveProperty('approval_status'); + }); + + it('omits droppedFields when no row seeds a readonly field', async () => { + const { p } = makeProtocol(); + const res: any = await p.createManyData({ + object: 'approval_case', + records: [{ title: 'A' }, { title: 'B' }], + context: { userId: 'u1' }, + }); + expect(res).not.toHaveProperty('droppedFields'); + }); + + it('a system-context bulk create keeps the field — no strip, no droppedFields', async () => { + const { p } = makeProtocol(); + const res: any = await p.createManyData({ + object: 'approval_case', + records: [{ title: 'A', approval_status: 'approved' }], + context: { isSystem: true }, + }); + expect(res).not.toHaveProperty('droppedFields'); + expect(res.records[0].approval_status).toBe('approved'); + }); +}); + +describe('insertManyData — per-row droppedFields on outcomes (#3455)', () => { + it('attaches the ingress strip to the matching outcome row only', async () => { + const insertMany = vi.fn(async (_object: string, rows: any[]) => + rows.map((r, i) => ({ ok: true, record: { id: `rec-${i + 1}`, ...r } })), + ); + const engine = { registry: { getObject: () => SCHEMA }, insertMany }; + const p = new ObjectStackProtocolImplementation(engine as any); + + const res: any = await p.insertManyData({ + object: 'approval_case', + records: [ + { title: 'A' }, + { title: 'B', approval_status: 'approved' }, + ], + context: { userId: 'u1' }, + }); + + expect(res.outcomes[0]).not.toHaveProperty('droppedFields'); + expect(res.outcomes[1].droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + // The strip really removed the field from what the engine inserted. + expect(insertMany.mock.calls[0][1][1]).not.toHaveProperty('approval_status'); + }); +}); + +describe('batchData — per-row droppedFields + context threading (#3455)', () => { + it('create rows surface the ingress strip and honour a system context', async () => { + const insert = vi.fn(async (_object: string, data: any, _options?: any) => ({ id: 'rec-1', ...data })); + const engine = { registry: { getObject: () => SCHEMA }, insert, update: vi.fn(), findOne: vi.fn() }; + const p = new ObjectStackProtocolImplementation(engine as any); + + const res: any = await p.batchData({ + object: 'approval_case', + request: { + operation: 'create', + records: [{ data: { title: 'A', approval_status: 'approved' } }], + }, + context: { userId: 'u1' }, + } as any); + + expect(res.results[0].droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + expect(res.results[0].record).not.toHaveProperty('approval_status'); + // [#3455] context is threaded to the insert (was hard-coded undefined before). + expect(insert.mock.calls[0][2].context).toEqual({ userId: 'u1' }); + }); + + it('a system-context batch create is exempt from the strip (context now threaded to it)', async () => { + const insert = vi.fn(async (_object: string, data: any) => ({ id: 'rec-1', ...data })); + const engine = { registry: { getObject: () => SCHEMA }, insert, update: vi.fn(), findOne: vi.fn() }; + const p = new ObjectStackProtocolImplementation(engine as any); + + const res: any = await p.batchData({ + object: 'approval_case', + request: { operation: 'create', records: [{ data: { title: 'A', approval_status: 'approved' } }] }, + context: { isSystem: true }, + } as any); + + expect(res.results[0]).not.toHaveProperty('droppedFields'); + expect(res.results[0].record.approval_status).toBe('approved'); + }); + + it('update rows surface the engine strip and keep droppedFields when returnRecords=false', async () => { + const update = vi.fn(async (object: string, _data: any, options?: any) => { + options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' }); + return { id: options.where.id }; + }); + const engine = { registry: { getObject: () => SCHEMA }, update, insert: vi.fn(), findOne: vi.fn() }; + const p = new ObjectStackProtocolImplementation(engine as any); + + const res: any = await p.batchData({ + object: 'approval_case', + request: { + operation: 'update', + records: [{ id: 'rec-1', data: { approval_status: 'approved' } }], + options: { returnRecords: false }, + }, + context: { userId: 'u1' }, + } as any); + + // returnRecords:false drops `record` but MUST keep the warning. + expect(res.results[0]).not.toHaveProperty('record'); + expect(res.results[0].droppedFields).toEqual([ + { object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }, + ]); + expect(update.mock.calls[0][2].context).toEqual({ userId: 'u1' }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 02853b5c95..5ee049355a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -616,6 +616,31 @@ function diffDroppedFields( return fields.length > 0 ? { object, fields, reason } : null; } +/** + * [#3455] Collapse a batch's per-row `DroppedFieldsEvent`s into one event per + * `(object, reason)` with the UNION of dropped field names. + * + * Used by the bulk-create surface (`createManyData`), whose `{ object, records, + * count }` response has no per-row slot to hang a `droppedFields` on. The + * insert-ingress strip (#3043) is static-`readonly` only — schema-uniform, so + * every row drops the same set — which makes an aggregated view faithful rather + * than lossy. Returns `[]` when nothing was dropped so callers can spread + * `...(x.length ? { droppedFields: x } : {})` and keep the omit-when-empty shape. + * The per-row `insertMany`/`batch` paths keep row precision instead (they have a + * per-row result to carry it). + */ +function mergeDroppedFieldEvents(events: DroppedFieldsEvent[]): DroppedFieldsEvent[] { + if (events.length === 0) return []; + const byKey = new Map }>(); + for (const ev of events) { + const key = `${ev.object}|${ev.reason}`; + let bucket = byKey.get(key); + if (!bucket) { bucket = { object: ev.object, reason: ev.reason, fields: new Set() }; byKey.set(key, bucket); } + for (const f of ev.fields) bucket.fields.add(f); + } + return Array.from(byKey.values()).map((b) => ({ object: b.object, fields: Array.from(b.fields), reason: b.reason })); +} + /** * Service Configuration for Discovery * Maps service names to their routes and plugin providers. @@ -3290,30 +3315,46 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // Batch Operations // ========================================== - async batchData(request: { object: string, request: BatchUpdateRequest }): Promise { - const { object, request: batchReq } = request; + async batchData(request: { object: string, request: BatchUpdateRequest, context?: any }): Promise { + const { object, request: batchReq, context } = request; const { operation, records, options } = batchReq; - const results: Array<{ id?: string; success: boolean; error?: string; record?: any }> = []; + const results: Array<{ id?: string; success: boolean; error?: string; record?: any; droppedFields?: DroppedFieldsEvent[] }> = []; let succeeded = 0; let failed = 0; - // [#3043] The batch endpoint is an external ingress and threads no - // context, so its creates are non-system: strip forged read-only columns. + // [#3043] The batch endpoint is an external ingress: strip forged + // read-only columns on create. [#3455] It DOES resolve an execution + // context (threaded by REST); thread it to every engine call so RLS/FLS + // and `readonlyWhen` run under the caller, and pass it to the strip so a + // system caller is correctly exempt (the pre-#3455 code hard-coded the + // strip context to `undefined`, treating every batch create as non-system). const batchSchema = this.engine.registry?.getObject(object); + // Spread form for options objects that already carry `where`/`onFieldsDropped` + // (`{}` spread is a safe no-op); arg form for `insert`, whose whole options + // arg is `undefined` when there is no context — exact parity with createData. + const ctxOpt = context !== undefined ? { context } : {}; + const insertCtx = context !== undefined ? { context } : undefined; for (const record of records) { try { switch (operation) { case 'create': { - const created = await this.engine.insert(object, stripReadonlyForInsert(batchSchema, record.data || record, undefined)); - results.push({ id: created.id, success: true, record: created }); + // [#3455] Diff the supplied row against the stripped one so a + // batch-create caller sees the same `droppedFields` a + // single-write create surfaces (#3431). + const stripped = stripReadonlyForInsert(batchSchema, record.data || record, context); + const ev = diffDroppedFields(object, record.data || record, stripped, 'readonly'); + const created = await this.engine.insert(object, stripped, insertCtx as any); + results.push({ id: created.id, success: true, record: created, ...(ev ? { droppedFields: [ev] } : {}) }); succeeded++; break; } case 'update': { if (!record.id) throw new Error('Record id is required for update'); - const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } }); - results.push({ id: record.id, success: true, record: updated }); + // [#3455] Collect the engine's LEGAL write strips per row. + const dropped: DroppedFieldsEvent[] = []; + const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any); + results.push({ id: record.id, success: true, record: updated, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) }); succeeded++; break; } @@ -3321,20 +3362,21 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // Try update first, then create if not found if (record.id) { try { - const existing = await this.engine.findOne(object, { where: { id: record.id } }); + const existing = await this.engine.findOne(object, { where: { id: record.id }, ...ctxOpt } as any); if (existing) { - const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id } }); - results.push({ id: record.id, success: true, record: updated }); + const dropped: DroppedFieldsEvent[] = []; + const updated = await this.engine.update(object, record.data || {}, { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); }, ...ctxOpt } as any); + results.push({ id: record.id, success: true, record: updated, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) }); } else { - const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) }); + const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) }, insertCtx as any); results.push({ id: created.id, success: true, record: created }); } } catch { - const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) }); + const created = await this.engine.insert(object, { id: record.id, ...(record.data || {}) }, insertCtx as any); results.push({ id: created.id, success: true, record: created }); } } else { - const created = await this.engine.insert(object, record.data || record); + const created = await this.engine.insert(object, record.data || record, insertCtx as any); results.push({ id: created.id, success: true, record: created }); } succeeded++; @@ -3342,7 +3384,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } case 'delete': { if (!record.id) throw new Error('Record id is required for delete'); - await this.engine.delete(object, { where: { id: record.id } }); + await this.engine.delete(object, { where: { id: record.id }, ...ctxOpt } as any); results.push({ id: record.id, success: true }); succeeded++; break; @@ -3370,7 +3412,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { total: records.length, succeeded, failed, - results: options?.returnRecords !== false ? results : results.map(r => ({ id: r.id, success: r.success, error: r.error })), + // [#3455] `returnRecords: false` drops the record payload but KEEPS + // `droppedFields` — it is a small write-observability warning, not the + // record data the flag suppresses. + results: options?.returnRecords !== false ? results : results.map(r => ({ id: r.id, success: r.success, error: r.error, ...(r.droppedFields ? { droppedFields: r.droppedFields } : {}) })), } as BatchUpdateResponse; } @@ -3382,6 +3427,19 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { request.records, request.context, ); + // [#3455] Surface the #3043 ingress strip, symmetric with single-write + // createData. Diff each supplied row against its stripped form, then + // AGGREGATE — the `{ records, count }` response has no per-row slot, and + // the insert-time strip is static-`readonly` only (schema-uniform), so a + // union view is faithful rather than lossy. + const dropped: DroppedFieldsEvent[] = []; + if (Array.isArray(request.records)) { + for (let i = 0; i < request.records.length; i++) { + const ev = diffDroppedFields(request.object, request.records[i], Array.isArray(rows) ? rows[i] : rows, 'readonly'); + if (ev) dropped.push(ev); + } + } + const merged = mergeDroppedFieldEvents(dropped); const records = await this.engine.insert( request.object, rows, @@ -3390,7 +3448,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return { object: request.object, records, - count: records.length + count: records.length, + ...(merged.length > 0 ? { droppedFields: merged } : {}), }; } @@ -3402,7 +3461,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { * engine with `insertMany` (ObjectQL has it); absent that, callers should * fall back to createManyData. */ - async insertManyData(request: { object: string, records: any[], context?: any }): Promise<{ object: string; outcomes: Array<{ ok: boolean; record?: any; error?: unknown }> }> { + async insertManyData(request: { object: string, records: any[], context?: any }): Promise<{ object: string; outcomes: Array<{ ok: boolean; record?: any; error?: unknown; droppedFields?: DroppedFieldsEvent[] }> }> { const engineInsertMany = (this.engine as any)?.insertMany; if (typeof engineInsertMany !== 'function') { throw new Error('insertManyData requires an engine with insertMany (framework#3172)'); @@ -3413,25 +3472,51 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { request.records, request.context, ); - const outcomes = await engineInsertMany.call( + // [#3455] Per-row #3043 ingress-strip observability. Unlike createManyData, + // this partial-success path HAS a per-row slot (`outcomes[i]`), so keep + // row precision: `stripReadonlyForInsert` maps 1:1 in order, so the i-th + // supplied row diffs against the i-th stripped row and rides the i-th + // outcome. Computed BEFORE the insert so a per-row engine failure never + // hides which fields the ingress had already dropped. + const rowsArr = Array.isArray(rows) ? rows : [rows]; + const perRowDropped: Array = Array.isArray(request.records) + ? request.records.map((rec, i) => diffDroppedFields(request.object, rec, rowsArr[i], 'readonly')) + : []; + const outcomes: Array<{ ok: boolean; record?: any; error?: unknown; droppedFields?: DroppedFieldsEvent[] }> = await engineInsertMany.call( this.engine, request.object, rows, request.context !== undefined ? { context: request.context } as any : undefined, ); + if (Array.isArray(outcomes)) { + for (let i = 0; i < outcomes.length; i++) { + const ev = perRowDropped[i]; + if (ev && outcomes[i]) outcomes[i].droppedFields = [ev]; + } + } return { object: request.object, outcomes }; } - async updateManyData(request: UpdateManyDataRequest): Promise { - const { object, records, options } = request; - const results: Array<{ id?: string; success: boolean; error?: string; record?: any }> = []; + async updateManyData(request: UpdateManyDataRequest & { context?: any }): Promise { + const { object, records, options, context } = request; + const results: Array<{ id?: string; success: boolean; error?: string; record?: any; droppedFields?: DroppedFieldsEvent[] }> = []; let succeeded = 0; let failed = 0; for (const record of records) { try { - const updated = await this.engine.update(object, record.data, { where: { id: record.id } }); - results.push({ id: record.id, success: true, record: updated }); + // [#3455] Two gaps the pre-#3455 loop had, both fixed per row: + // 1. `context` was never threaded — bulk updates ran the engine + // context-less, so RLS/FLS and `readonlyWhen` evaluated without + // the caller's principal. Thread it like single-write updateData. + // 2. `onFieldsDropped` was never wired — the same static `readonly` + // (#2948) / `readonlyWhen` (#3042) strips that single-write now + // surfaces (#3431) happened silently here. Collect per row. + const dropped: DroppedFieldsEvent[] = []; + const opts: any = { where: { id: record.id }, onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } }; + if (context !== undefined) opts.context = context; + const updated = await this.engine.update(object, record.data, opts); + results.push({ id: record.id, success: true, record: updated, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) }); succeeded++; } catch (err: any) { results.push({ id: record.id, success: false, error: err.message }); diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index d7243e0d49..ef0892f52f 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -28,10 +28,12 @@ export interface HonoCorsOptions { /** * Response headers exposed to JS (`Access-Control-Expose-Headers`). * - * Defaults to `['set-auth-token']` so that better-auth's `bearer()` plugin - * can hand rotated session tokens to cross-origin clients. User-supplied - * values are merged with this default — `set-auth-token` is always - * exposed unless CORS is disabled entirely. + * Defaults to `['set-auth-token', 'x-objectstack-dropped-fields']` so that + * better-auth's `bearer()` plugin can hand rotated session tokens to + * cross-origin clients, and a browser can read the single-write + * `X-ObjectStack-Dropped-Fields` warning header (#3431/#3455). User-supplied + * values are merged with these defaults — they are always exposed unless CORS + * is disabled entirely. */ exposeHeaders?: string[]; credentials?: boolean; diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 085d1c1675..7e5256da41 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -449,7 +449,11 @@ export class HonoServerPlugin implements Plugin { // the browser with "Failed to fetch" (objectui#2572 dogfood find; // same split-origin class as the #2548 Bearer fixes). const defaultAllowHeaders = ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Tenant-ID', 'X-Environment-Id', 'If-Match']; - const defaultExposeHeaders = ['set-auth-token']; + // `x-objectstack-dropped-fields` (#3455): expose the single-write + // drop header (#3431) to cross-origin JS. Kept in lockstep with the + // `@objectstack/hono` adapter's default. The body `droppedFields` + // channel remains the primary, cross-origin-safe surface. + const defaultExposeHeaders = ['set-auth-token', 'x-objectstack-dropped-fields']; const allowHeaders = corsOpts.allowHeaders ?? defaultAllowHeaders; const exposeHeaders = Array.from(new Set([ ...defaultExposeHeaders, diff --git a/packages/spec/src/api/batch.zod.ts b/packages/spec/src/api/batch.zod.ts index c13865354a..110b22196c 100644 --- a/packages/spec/src/api/batch.zod.ts +++ b/packages/spec/src/api/batch.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ApiErrorSchema, BaseResponseSchema, RecordDataSchema } from './contract.zod'; +import { DroppedFieldsEventSchema } from '../data/data-engine.zod'; /** * Batch Operations API @@ -126,6 +127,16 @@ export const BatchOperationResultSchema = lazySchema(() => z.object({ errors: z.array(ApiErrorSchema).optional().describe('Array of errors if operation failed'), data: RecordDataSchema.optional().describe('Full record data (if returnRecords=true)'), index: z.number().optional().describe('Index of the record in the request array'), + droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( + 'Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from ' + + 'THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` ' + + '(#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop ' + + 'different fields on different rows (`readonlyWhen` is record-state-dependent). Present ' + + 'ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). ' + + 'A single response header cannot express per-row drops, so this body field is the ' + + 'canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. ' + + 'Optional — omit-when-empty keeps the shape backward-compatible.' + ), })); export type BatchOperationResult = z.infer; diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 61ddf10b3b..1845f9657a 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -502,6 +502,16 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ object: z.string().describe('Object name'), records: z.array(z.record(z.string(), z.unknown())).describe('Created records'), count: z.number().describe('Number of records created'), + droppedFields: z.array(DroppedFieldsEventSchema).optional().describe( + 'Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 ' + + 'create-ingress strip removed before the rows were written. AGGREGATED across the batch ' + + '(one event per object/reason with the union of dropped field names) rather than per-row, ' + + 'because the insert-time strip is static-`readonly` only — schema-uniform, so every row ' + + 'drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + + 'without them (count/success unchanged). Optional — omit-when-empty keeps the shape ' + + 'backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` ' + + 'on each result instead — see BatchOperationResultSchema.)' + ), })); /** From 14429cf7e83e17989cdaf230a142c25b7abe0184 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:59:28 +0800 Subject: [PATCH 2/3] docs(spec): regenerate api reference for bulk droppedFields fields (#3455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated from the batch/protocol Zod schemas — BatchOperationResult (per-row) and CreateManyDataResponse (aggregated) gained droppedFields. Regenerated via gen:schema && gen:docs; these files are generated, not hand-edited. Co-Authored-By: Claude Fable 5 --- content/docs/references/api/batch.mdx | 1 + content/docs/references/api/protocol.mdx | 1 + 2 files changed, 2 insertions(+) diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index ec01c8c79d..514086fdb2 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -63,6 +63,7 @@ const result = BatchConfig.parse(data); | **errors** | `{ code: string; message: string; category?: string; details?: any; … }[]` | optional | Array of errors if operation failed | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b64b4d98f0..d9e71e3548 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -245,6 +245,7 @@ const result = AiInsightsRequest.parse(data); | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied `readonly` fields the #3043 create-ingress strip removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the union of dropped field names) rather than per-row, because the insert-time strip is static-`readonly` only — schema-uniform, so every row drops the same set. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) | --- From 12323caa8659de78da9f655691c90145a56a27b0 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:03:52 +0800 Subject: [PATCH 3/3] docs(spec): regenerate objectstack-api skill references for droppedFields transitive deps (#3455) batch.zod.ts now imports DroppedFieldsEventSchema from data-engine.zod, whose transitive imports (kernel/execution-context, security/explain) pull those into the objectstack-api skill's referenced-schema set. Regenerated via gen:skill-refs. Co-Authored-By: Claude Fable 5 --- skills/objectstack-api/references/_index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index fc712ffdf1..086344b223 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -23,9 +23,12 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/api/contract.zod.ts` — Standard Create Request - `node_modules/@objectstack/spec/src/api/realtime-shared.zod.ts` — Realtime Shared Protocol +- `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node +- `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Execution Context Schema +- `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema