Counter-intuitive behaviors & gotchas¶
Quick reference for things that look like a bug but aren't. Each entry explains the "intuitive guess" first, then what SpecStar actually does, and links to the relevant guide.
If a behavior here genuinely bites you, please open an issue — most of these are 0.x trade-offs that may sharpen up before 1.0.
Reads / response shape¶
GET /{model}/{id} returns an envelope, not the bare resource¶
| Intuitive guess | Actual behavior |
|---|---|
GET /user/123 → {name: "...", email: "..."} |
GET /user/123 → {"data": {...}, "revision_info": {...}, "meta": {...}} |
The envelope is intentional: the same endpoint can return data, revision info, and metadata together. To get the bare data object:
GET /user/123/data, orGET /user/123?returns=only-data— theonly-*selector unwraps the section. (Plain?returns=datastill wraps it as{"data": {...}}.)
To change the default shape for every GET, set default_get_returns
(e.g. spec.configure(default_get_returns="only-data")).
Note the asymmetry: POST / PUT return a flat RevisionInfo (top-level
resource_id, revision_id, created_by, …) — a different shape from this GET
envelope. A client reads the new id from the flat write response, then reads the
resource back through the envelope.
See API conventions.
?partial=true on a list returns data: {} for every row¶
partial is a field selector (slash-prefixed paths), not a boolean.
?partial=true is normalised to the path /true which matches no field in
your struct, so the projector strips data down to an empty object (a
boolean-looking partial value also emits a SpecStarWarning). Use
slash-prefixed field names instead:
See API conventions § partial.
Three id fields in every response¶
| Field | Where | Use for |
|---|---|---|
resource_id |
meta.resource_id |
URL paths, stable across revisions |
revision_id |
revision_info.revision_id |
switch, revision-targeted reads |
uid |
meta.uid |
Internal — do not put in URLs (differs per revision) |
Lists & filters¶
Bare ?field=value query params are silently ignored¶
| Intuitive guess | Actual behavior |
|---|---|
GET /task?name=Alice returns Alice's tasks |
Returns all tasks; name is silently dropped |
Filtering goes through the structured qb DSL or data_conditions /
conditions. See Query Builder.
Default page size is effectively unlimited until you set it¶
SPECSTAR_DEFAULT_QUERY_LIMIT defaults to 2**32 - 1. Set a sane limit
(e.g. 1000) for production. Per-request ?limit= overrides it.
limit=0 returns zero rows, not "all rows"¶
limit is the literal page size. 0 means "give me nothing." For all
rows, omit limit (or set it to a large finite number).
Writes¶
Unknown fields are silently dropped (until you opt in)¶
By default, POST / PUT with extra keys returns 200 and the extras
are gone. A typo'd field = lost data. Opt into strict mode:
With it on, unknown fields return 422. See API
conventions § Strictness.
resource_id cannot be set through the request body¶
POST /note {"title": "a", "resource_id": "my-id"} # 422
PUT /note/<id> {"title": "a", "resource_id": "x"} # 422
PATCH /note/<id> [{"op":"replace","path":"/resource_id","value":"x"}] # 422
resource_id is server-generated at creation and immutable afterwards, so
it never belongs in POST / PUT / PATCH bodies. SpecStar rejects it with
422 rather than silently dropping it (the previous behavior — see
CHANGELOG).
To customise how ids are generated, pass id_generator= to
spec.add_model(...).
The one exception: if your Struct legitimately declares a field named
resource_id, the guard steps aside and treats it as ordinary data.
A custom id supplied programmatically (rm.create(data, resource_id=...)
or a custom id_generator) must be safe in file paths and URLs: ids with
/, \, or control characters are rejected with a ValidationError. A /
would otherwise break disk storage (the id is used as a filename) and make the
resource unreachable over HTTP — /{model}/{resource_id} is a single path
segment, so GET /model/a/b (and even a%2Fb) returns 404. Stick to letters,
digits, :, -, _.
PUT is full-replace, not upsert¶
| Intuitive guess | Actual behavior |
|---|---|
PUT /user/unknown-id creates the user |
Returns 404 — PUT requires the resource to exist. Use POST /user to create. |
PATCH accepts two flavors — pick by shape (array = ops, object = merge)¶
PATCH understands both REST patch standards on the same endpoint:
PATCH /user/123 [{"op":"replace","path":"/name","value":"new"}] # RFC 6902 JSON Patch (array)
PATCH /user/123 {"name": "new"} # RFC 7386 Merge Patch (object)
A JSON array is treated as RFC 6902 operations; a JSON object is a
RFC 7386 merge patch (partial update; null deletes a field). Set
Content-Type: application/json-patch+json or application/merge-patch+json
to be explicit. See API conventions § PATCH.
Same-content writes are de-duplicated¶
A PUT that produces byte-identical content as the current revision
returns 200 but does not create a new revision. Compare the
returned revision_id to the prior one to detect a dedup. See API
conventions § Revisions and mutability.
DELETE returns 200 + meta body, not 204 No Content¶
Useful when callers want the post-delete metadata (e.g. to confirm soft
delete, capture timestamps). If you only care about success, just check
status_code < 300.
Soft-deleted resources return 410 Gone, not 404¶
Distinct from "never existed" 404. Pass ?include_deleted=true to
read them through the same endpoints.
Programmatic list_resources() includes soft-deleted rows (HTTP excludes them)¶
The two layers disagree on the default:
| Call | Soft-deleted included? |
|---|---|
rm.list_resources() / count_resources() / iter_all() (bare) |
Yes — the query's is_deleted is UNSET, i.e. no filter |
GET /{model} (no is_deleted param) |
No — the route param defaults to False |
To exclude them in a single programmatic call, pass it explicitly:
from specstar.query_types import ResourceMetaSearchQuery
rm.list_resources(ResourceMetaSearchQuery(is_deleted=False)) # live only
Or set a default for all programmatic list/count/iter calls (non-breaking —
defaults to None = include both):
spec.configure(default_is_deleted=False) # exclude soft-deleted by default
# None = include both (default) · False = exclude · True = only deleted
# An explicit is_deleted in the query always wins.
Unique() ignores soft-deleted rows¶
A Unique() constraint only considers live resources. After you soft-delete
a row, its value (SKU, employee id, …) can be reused by a new resource. This is
often desirable, but surprising if you expected the value to stay reserved.
Permanently delete (or keep the row) if you need the value held.
Blobs / attachments¶
Deleting a resource does not delete its blobs¶
Blobs (Binary / file_id) are not reference-counted or garbage-collected
on delete, so deleting a resource leaves its uploaded files behind. Do not
naively delete a resource's blobs in an on-delete handler: the same file_id
can be referenced by other resources, other fields, or older revisions of
the same resource (and soft-deleted resources can still be restored). Deleting
it would corrupt those references.
Safe cleanup is a refcount-aware sweep, not an on-delete hook. Recipe:
# Run as a periodic / manual GC, never inline on delete.
# A blob is an orphan only if NO live resource/revision references it.
def collect_orphan_blobs(manager, *, dry_run=True):
referenced: set[str] = set()
# 1. gather every file_id referenced by any live revision of any resource
for meta in manager.iter_all(): # all live resources
for rev_id in manager.list_revisions(meta.resource_id):
data = manager.get_resource_revision(meta.resource_id, rev_id).data
referenced |= file_ids_in(data) # your model-specific extractor
# 2. delete blobs that nothing references
orphans = [bid for bid in manager.blob_store.list_blobs() if bid not in referenced]
if not dry_run:
for bid in orphans:
manager.blob_store.delete(bid)
return orphans
Adapt file_ids_in(...) and the blob-store listing to your storage backend;
both a full revision scan and blob enumeration are required to be correct.
Versioning / migration¶
Resources registered with bare add_model(Model) store schema_version=None¶
Later upgrading to Schema(Model, "v2").step("v1", ...) then fails with
"No migration path from version None to 'v2'". Two recoveries:
- Recommended from day one: always use
Schema(Model, "v1")even before you have migrations. - Already have unversioned data: register a
step(None, ...)migration. See Migrations howto.
switch needs the full revision_id or a bare revision number¶
Both forms work; anything else returns 400 with a hint:
POST /user/{rid}/switch/3 # OK — normalised to {rid}:3
POST /user/{rid}/switch/{rid}:3 # OK — explicit
POST /user/{rid}/switch/whatever # 400 — invalid format
switch to an older revision may raise RevisionNotMigratedError¶
After migrate(resource_id) bumps meta.schema_version to the new
target, older revisions still sit at their original schema_version.
switch to one of those raises RevisionNotMigratedError (400). Fix
by migrating that specific revision first:
Configuration¶
configure(admin=...) sets the RBAC root user name, not a URL path¶
spec.configure(admin="alice") # username "alice" gets full access
spec.configure(admin="/admin") # username "/admin" — almost certainly NOT what you wanted
The web admin UI is the separate TypeScript app under wizard/.
Programmatic mgr.create/update/... use anonymous + now() if you don't set a user¶
By default (non-strict) a programmatic write with no operation context records
the same values an unauthenticated HTTP request would — created_by="anonymous"
and the current UTC time — so it "just works". To attribute writes to a real
user, set a default or wrap the call:
spec.configure(default_user="me", default_now=datetime.utcnow)
# or, per-call
with mgr.using(user="me", now=datetime.utcnow()):
mgr.create(...)
If you'd rather a missing context be a hard error (no silent anonymous), opt
into strict mode — then create/update/migrate/switch without a context
raise MissingOperationContextError:
MigrateRouteTemplate is opt-in¶
/{model}/migrate/... endpoints are not registered by default.
Add them explicitly before add_model():
from specstar.crud.route_templates.migrate import MigrateRouteTemplate
spec.add_route_template(MigrateRouteTemplate())
The mounted paths follow model_naming and are singular —
/issue/migrate/execute, not /issues/migrate/execute.
Backup / restore¶
/import accepts both a multipart file field and a raw body¶
GET /{model}/export streams application/octet-stream.
POST /{model}/import accepts either multipart/form-data with a file
field or the archive as a raw application/octet-stream body — so the
export bytes round-trip directly (--data-binary @dump.acbak). See Backup &
Restore for curl recipes.
GraphQL is opt-in¶
The README lists GraphQL as a feature; that means supported, not on by default. To use it:
from specstar.crud.route_templates.graphql import GraphQLRouteTemplate
spec.add_route_template(GraphQLRouteTemplate())
Naming / paths¶
Multi-word class names are kebab-cased, not snake-cased¶
BlogPost → /blog-post, XMLNode → /xml-node (acronyms are treated as one
word, not split letter-by-letter). The default
model_naming="kebab" is lowercase with hyphens; see Routes howto §
model_naming reference for a full
matrix and an example of supplying a callable (e.g. to pluralise).
Path params are always resource_id, not id¶
GET /user/{resource_id} — there is no /user/{id}. Same for every
generated route.