Skip to content

ActionEngineFacade.find(object, query) takes a bare filter while insert/update/delete take explicit shapes — the type says neither, and reading it wrong returns empty with no error #14175

Description

@os-warren

Found by an application (objectstack-ai/duly) whose headline onboarding action was a silent no-op in production for its whole life, with a green test suite. Filed as a contract/typing gap rather than a runtime defect — the runtime behaves consistently; nothing declares which convention it is.

The asymmetry

buildActionEngineFacade in @objectstack/runtime 17.2.0:

async update(object, id, data) {
  await ql.update(object, data, { where: { id }, context });
},
async delete(object, idOrIds) { /* ids, explicit */ },
async find(object, query) {
  const where = query && Object.keys(query).length ? { where: query } : {};
  const rows = await ql.find(object, { ...where, context });
  return Array.isArray(rows) ? rows : rows?.value ?? [];
}

insert, update and delete all take arguments whose shape is unambiguous from the signature. find takes a bare filter and builds the ObjectQL envelope itself — but it is the one method whose parameter is named query, which is exactly what the envelope is called everywhere else in the platform.

ActionEngineFacade.find in @objectstack/spec types that parameter as a plain string-keyed record of unknown and documents nothing about it. So the runtime's implementation is the only thing that decides, and it is not visible from where a handler author is working.

Why reading it wrong is silent

An author who passes the envelope — the shape ql.find itself takes, and the shape every other read in their codebase uses — gets:

{ where: { where: { position_code: 'qa_lead', active: true } } }

No row has a field called where. The read returns empty, with no error. The handler runs to completion and reports a successful run over zero rows.

Measured in the app, dispatching through data.executeAction against a booted kernel with one matching row, the only variable being which facade the handler receives:

facade rows found records created
the runtime's own (wraps in where) 0 0
a flat one (passes the filter through) 1 1

There is a second trap inside the first: Object.keys(query).length is falsy for {}, so the wrapping is skipped entirely for an unfiltered read. In the app that found this, the one find call with no filter was the one call that worked — so the handler looked partially alive, which is worse than uniformly dead.

Why it survives testing

The app's own test double implemented find(object, query) reading query.where — honouring the handler's assumption rather than the runtime's behaviour. Every assertion passed against a query shape production cannot produce. validate, typecheck, test and build were all green.

That is not a bad test double so much as an inevitable one: a hand-written fake encodes the author's belief about the contract, and when the contract is undeclared the fake and the handler are wrong in the same direction by construction. Nothing in the toolchain compares either to the runtime.

Suggested direction

Any of these closes it; the first is cheapest:

  1. Type the parameter. Give ActionEngineFacade.find a filter type rather than an open record, and say in the doc comment that it is a filter and not an ObjectQL envelope. A named type would have made the app's mistake a compile error.
  2. Reject an envelope. find could throw when handed an object whose only key is where — that shape is never a legitimate filter (no object has a field called where; the platform's own reserved-word linting could confirm it), and today it is the exact shape of the mistake.
  3. Ship the facade as a test double, so applications stop hand-writing one that encodes their assumption.

The general point is the one worth acting on: find is the only method on this facade whose argument shape cannot be inferred from its signature, and it is the only one where guessing wrong is silent. The other three would throw or misbehave loudly.

Reported app-side as objectstack-ai/duly#79.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions