API

Backend

Write

On a Postgres-backed namespace, []string, []int, []uint, []float and []bool attributes are stored as native Postgres arrays, in element order, and filterable ones are indexed for array filters. An undeclared array attribute takes its type from its elements, so declare an attribute whose first write is an empty array. []uuid and []datetime return 422 UnsupportedByStore with feature: "schema.type".

Contains / ContainsAny filters and their Not forms: supported

On a Postgres-backed namespace, patches and filtered writes run in the write’s single transaction, in turbopuffer’s order: delete_by_filter, then patch_by_filter, then upserts, patch_rows / patch_columns, and deletes.

  • A patch writes only the attributes it names; null clears one. A patch to an id that does not exist is ignored and creates no row.
  • A patched attribute not yet in the schema is declared by the schema in the same write or inferred from the value, as on an upsert.
  • Unlike turbopuffer, a client vector attribute can be patched; it is validated like an upserted vector.
  • An attribute Layer embeds on write, and its vector, cannot be patched: Layer cannot recompute the embedding from a patch, so the write is rejected with a validation_error. Upsert the full row instead.
  • delete_by_filter and patch_by_filter take the query filter syntax. Past 5,000,000 matching rows for a delete or 50,000 for a patch, the write is a 400 unless delete_by_filter_allow_partial / patch_by_filter_allow_partial is set, in which case the first rows are written and rows_remaining is true.
  • rows_patched and rows_deleted count the rows written; rows_affected is their sum with rows_upserted.
  • A write any part of which is rejected, including a schema change beside it, has no effects.

Row patches: supported

For a nested payload that is only returned with a row, declare an explicit {"type":"json","filterable":false} attribute. For example:

{
  "schema": {"links": {"type": "json", "filterable": false}},
  "upsert_rows": [{
    "id": "job-52", "kind": "job", "project_record_id": "project-52",
    "links": [
      {"label": "Job folder", "system": "SharePoint", "url": "https://example.test/jobs/52"},
      {"label": "Accounting", "system": "QuickBooks", "url": "https://example.test/accounts/52"}
    ]
  }]
}

Query, search and collapse preserve that JSON value when it is included in the returned attributes, including MCP structured results. JSON attributes cannot be filtered, sorted or ranked; the declaration must set filterable to false. Types are not inferred from nested objects. This is a Postgres extension; turbopuffer’s schema does not accept nested JSON attributes. Keep separately searchable links as rows with kind: "link" and query them with both project_record_id and kind equality filters.

On a Postgres-backed namespace, conditions use the query filter syntax and are evaluated against each targeted row’s stored value in the write’s transaction:

  • upsert_condition: an existing row is replaced only if it passes. A new id is always inserted. A $ref_new value reads the row being written, where a missing attribute is null. ["id", "Eq", null] inserts without overwriting, and this condition keeps only newer versions:

    ["version", "Lt", {"$ref_new": "version"}]
  • patch_condition: applies to patch_rows and patch_columns. A row is patched only if it passes; $ref_new reads the row as patched.

  • delete_condition: a row is deleted only if it passes. $ref_new is null for every attribute, and a missing id is skipped.

  • rows_affected counts only the rows whose condition passed.

  • A condition on an undeclared attribute, or a $ref_new whose type differs from the compared attribute, is a 400 validation error. The whole write is rejected with no effects.

Conditional writes: supported

Layer stamps every row-producing write with _hevlayer_upserted_at for supported stores. The stamp is what holds the read watermark; the full set of reserved attributes Layer manages on a row lives in the document model.

To branch or copy a namespace, see Branching.

Upsert and delete

POST /v2/namespaces/{namespace} accepts a JSON body. The first schema write creates the namespace. With the common client:

await client.write_namespace("preview_docs", {
    "distance_metric": "cosine_distance",
    "schema": {
        "vector": {"type": "[3]f32"},
        "body": {"type": "string", "full_text_search": True},
        "category": {"type": "string"},
        "year": {"type": "int"},
    },
    "upsert_rows": [{"id": "atlas", "vector": [1, 0, 0],
                     "body": "moon geology", "category": "space", "year": 2026}],
})
_, err := client.WriteNamespace(ctx, "preview_docs", hevlayer.TurbopufferWriteRequest{
    "distance_metric": "cosine_distance",
    "schema": map[string]any{
        "vector": map[string]any{"type": "[3]f32"},
        "body": map[string]any{"type": "string", "full_text_search": true},
        "category": map[string]any{"type": "string"},
        "year": map[string]any{"type": "int"},
    },
    "upsert_rows": []map[string]any{{"id": "atlas", "vector": []float64{1, 0, 0},
        "body": "moon geology", "category": "space", "year": 2026}},
})
await client.writeNamespace("preview_docs", {
  distance_metric: "cosine_distance",
  schema: {
    vector: { type: "[3]f32" },
    body: { type: "string", full_text_search: true },
    category: { type: "string" }, year: { type: "int" },
  },
  upsert_rows: [{ id: "atlas", vector: [1, 0, 0],
    body: "moon geology", category: "space", year: 2026 }],
});
curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"distance_metric":"cosine_distance","schema":{"vector":{"type":"[3]f32"},"body":{"type":"string","full_text_search":true},"category":{"type":"string"},"year":{"type":"int"}},"upsert_rows":[{"id":"atlas","vector":[1,0,0],"body":"moon geology","category":"space","year":2026}]}'

Delete by string ID using the same route:

await client.write_namespace("preview_docs", {"deletes": ["atlas"]})
await client.writeNamespace("preview_docs", { deletes: ["atlas"] });
curl --fail-with-body "$LAYER_GATEWAY_URL/v2/namespaces/preview_docs" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -H 'Content-Type: application/json' -d '{"deletes":["atlas"]}'

Patches, conditions and column writes

On other stores, write-shape support follows the backend notes above. Use patch_rows to modify named fields without replacing a complete document only when the selected store supports patches.

Status

Layer validates the body before forwarding and can fail independently of the backing store, so the write path carries a few statuses a plain proxy wouldn’t:

  • 200 OK — applied to the selected store. Gateway bookkeeping depends on the backend.
  • 422 Unprocessable Entity — Layer rejected the body before forwarding: no recognized native write operation, a reserved _hevlayer_* attribute name, a removed custom-write key, or a schema type the configured store cannot represent. The body is a Layer error ({ "error": "validation_error", … } or { "error": "UnsupportedByStore", … }), not a store one.
  • Upstream passthrough — any non-2xx the backing store returns is relayed verbatim.
  • 502 Bad Gateway — Layer could not reach the backing store ({ "error": "upstream_error", … }); the write did not apply.

The passthrough case includes a failed conditional write (upsert_condition, patch_condition, delete_condition) — turbopuffer’s error body comes back untouched.

esc