Writing documents
Flow Query covers the full write lifecycle: insert, update (full-replace), patch (partial), status transition, data-type transition, and delete. All writes participate in the current debe session and are rolled back automatically if the hook fails.
Schema validation (Joi) runs by default on every write. See Validation for the control surface.
insertOne / insertManyâ
const result = await bob.flowQuery('vendors/vendor')
.insertOne({
id: 'vendor-001',
name: 'Acme Corp',
legalEntityName: 'Acme Corp LLC',
email: 'contact@acme.com',
})
result.ok // true
result.docIds // ['VJxyzâŚ']
result.data // inserted document(s)
// Many documents
await bob.flowQuery('finance/bill').insertMany([doc1, doc2, doc3])
Rules:
- Missing required fields â throws
bad_validation_request. - Extra non-schema fields â silently stripped on insert.
- Type coercion is applied (e.g.
'5'â5forJoi.number()). - Defaults from the schema are auto-applied.
- Envelope fields (
docId,info.createdAt,info.createdBy, etc.) are populated from context.
Use .insert() (no terminal) when you want to prepare an insert for
a bulk â see Bulk.
updateOne / updateMany â full replaceâ
Full replace of the document data. The payload must contain every required field.
await bob.flowQuery('vendors/vendor')
.docId(docId)
.updateOne({
id: 'vendor-001',
name: 'Acme Corp',
legalEntityName: 'Acme Corp LLC',
email: 'updated@acme.com',
status: 'active',
})
// Update all matching documents
await bob.flowQuery('vendors/vendor')
.where('data.archived', 'eq', true)
.updateMany({ /* full replacement payload */ })
Missing required fields â throws. If you want to touch only a subset,
use patchOne / patchMany or .mutate().
Modify immutable documentsâ
Documents in an immutable status (e.g. posted) reject ordinary
updates. Opt in explicitly when the hook has the authority to do so:
await bob.flowQuery('finance/bill')
.docId(docId)
.updateOne(payload, { modifyImmutable: true })
patchOne / patchMany â partial updateâ
Merge-style update â only touches the fields you pass. Unknown fields are rejected (unlike insert, which strips them).
await bob.flowQuery('vendors/vendor')
.docId(docId)
.patchOne({ phoneNumber: '555-9999' })
await bob.flowQuery('vendors/vendor')
.where('data.name', 'eq', name)
.patchMany({ standingStatus: 'preferred' })
Patch behaviour:
| Behaviour | Insert | Patch |
|---|---|---|
| Missing required fields | throws | ok (not touched) |
| Extra unknown fields | stripped | throws |
| Type coercion | yes | yes |
| Null for optional fields | allowed | allowed |
Patching a non-existent docId is not an error â ok: false /
modifiedCount: 0 is returned. Check the return value if you care.
setStatus â status transitionâ
await bob.flowQuery('finance/bill')
.docId(docId)
.setStatus('posted')
setStatus enforces the status transition rules defined on the
document and runs the same pre/post hooks a write would. To avoid
hardcoded strings, resolve the target with statusRef:
const posted = bob.flowQuery('finance/bill').statusRef().value('posted')
await bob.flowQuery('finance/bill').docId(docId).setStatus(posted)
See References for the full statusRef API.
setDataType â data-type transitionâ
await bob.flowQuery('finance/bill')
.docId(docId)
.setDataType('bill-payment-reconciliation-status', 'paid')
Same pattern as setStatus â the target value can be resolved via
fieldDataType or slugDataType to avoid magic strings.
deleteOne / deleteMany â soft deleteâ
Soft delete: the envelope status is flipped to 'deleted', the
document is hidden from every subsequent query by default, but the
document's data is preserved intact.
const result = await bob.flowQuery('vendors/vendor')
.docId(docId)
.deleteOne()
result.ok // true
result.deleteCount // 1
// Bulk soft delete
await bob.flowQuery('vendors/vendor')
.where('data.name', 'contains', '__TEST_')
.limit(1000)
.deleteMany()
.includeDeleted() reveals soft-deleted documents:
const row = await bob.flowQuery('vendors/vendor')
.docId(deletedDocId)
.includeDeleted()
.getOne()
row.status // â 'deleted' (envelope)
row.data.status // â 'active' (business â unchanged)
Use .delete() (no terminal) to pre-configure a delete for bulk
execution.
Validationâ
Writes are validated against the document's Joi schema by default.
Bypass the schema with .skipValidation() for migrations, system
writes, or pre-validated payloads:
await bob.flowQuery('audit/log')
.skipValidation()
.insertOne(rawData)
Two helpers let you validate without writing â useful for UI pre-flighting:
await bob.flowQuery('vendors/vendor').validateData(payload)
// â { ok: boolean, errors: [...] }
await bob.flowQuery('vendors/vendor').validateDataOrThrow(payload)
// â throws on failure, returns void on success
Helpersâ
// Produce a fully-defaulted empty payload for a document
const empty = await bob.flowQuery('vendors/vendor').generateEmptyDocument()
// Enforce the document kind at call site
bob.flowQuery('finance/settings').isConfigDocumentOrThrow()
bob.flowQuery('finance/settings').isSettingsOrThrow()
Envelope overridesâ
Out-of-the-ordinary writes sometimes need to set fields that are normally managed by the framework. Three overrides are available:
.docIdOverride('CUSTOM-DOC-ID') // custom docId on insert
.overrideCreatedAt(new Date('2024-01-01'))
.overrideUpdatedAt(new Date('2024-02-01'))
Use these sparingly â they exist for migration scripts and data backfills.
Tracking metadataâ
Every mutation updates the envelope metadata automatically:
doc.info.createdAt // set on insert
doc.info.createdBy // set from FlowUser
doc.info.updatedAt // set on every update / patch
doc.info.updatedBy // set from FlowUser
No need to write these explicitly.
Session and transaction behaviourâ
All writes run inside the hook's debe session by default. Reads within the same session see the uncommitted writes; nothing escapes to other sessions until the hook commits successfully.
.skipSession() commits immediately to the database and cannot be
rolled back by the session. Use it for writes that must survive a
hook-level failure (audit logs, metrics) â never for business data.
Next: Mutations â