mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
c38b3bf5cd
* feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378)
Foundational change for PLAN-1377 — playbooks become first-class invokable
procedures. Two new optional fields land on the Playbooks collection
schema:
- `invocation_slug` (text, kebab-case, unique-per-workspace among non-null
values): enables `/pad <slug>` direct invocation. Nullable so
trigger-only playbooks (e.g. on-release checklists) don't need one.
- `arguments` (json, array of {name, type, required, default, description}):
declares the playbook's argument contract; mirrors the body's
`## Arguments` section in queryable form.
Plumbing pieces:
- `models.FieldDef` grows two general-purpose options — `Pattern` for
regex validation and `UniqueScope` for collection-level uniqueness.
Both are opt-in; existing schemas are unaffected.
- `items.ValidateFields` learns the `json` field type (accepts any
JSON-decodable value) and applies `Pattern` to string-typed values.
- `handlers_items.checkUniqueFields` queries `Store.ListItems` to enforce
`UniqueScope == "workspace_collection"` on create + update.
- Two migrations (SQLite 054, Postgres 033) JSON-patch the playbooks
schema on existing workspaces so the new fields show up without a
workspace re-init.
- TypeScript `FieldDef` mirrors the Go side.
Parent: PLAN-1377.
* fix(playbooks): address Codex review round 1 findings (TASK-1378)
P1 — EditCollectionModal now round-trips opaque pattern/unique_scope
metadata. EditableField carries the new keys; the load + save paths
preserve them so re-saving the playbooks collection from the UI doesn't
strip server-side validation rules the modal doesn't yet expose
dedicated controls for. fieldFromDef mirrors the change for templates.
P2 — checkUniqueFields' pre-write ListItems check is now backed by a
partial unique index (idx_items_invocation_slug_per_collection,
SQLite + Postgres) scoped to non-empty, non-deleted rows. The pre-check
still gives users a friendly error message in the common case; the
index closes the TOCTOU race between two concurrent writers. The
create-conflict error message is now generic enough to cover both the
slug constraint and the new invocation_slug index.
P2 — `json` field type now rejects raw strings, numbers, and bools. Only
objects, arrays, and null are accepted, so a generic web text input
can't silently corrupt a structured field by emitting "[]" instead of
an actual array. FieldEditor.svelte routes `json` fields to a
read-only summary in both readonly and edit modes; dedicated editors
(like TASK-1384's playbook editor that owns `arguments`) own the
structured form.
P3 — invocation_slug regex now requires a minimum of two characters
(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) in the Go const, the SQLite migration,
the Postgres migration, and the validate tests. Single-letter slugs
would shadow plausible NL tokens (e.g. `/pad a ...`) and the doc
comment already claimed the two-char floor; this aligns code with
intent.
Parent: PLAN-1377.
* fix(playbooks): address Codex review round 2 findings (TASK-1378)
P2.1 — checkUniqueFields no longer passes IncludeArchived=true. The
application-layer pre-check now matches the partial unique index's
`deleted_at IS NULL` predicate so a soft-deleted playbook releases its
slug back to the pool and reclaiming it succeeds instead of 409'ing.
P2.2 — handleUpdateItem now maps UNIQUE constraint / duplicate key
errors from UpdateItem to HTTP 409, mirroring the create path. A true
concurrent-update race that slips past checkUniqueFields and trips the
partial unique index used to surface as a misleading 500.
(Not addressed in this round: Codex's third finding — concern about the
partial unique index applying to "every collection" — is, on close
reading, not what the index does. `ON items(collection_id, json_extract(...))`
scopes uniqueness to the (collection_id, slug) pair, so two items in
different collections with the same `invocation_slug` value coexist
fine. The migration-failure risk is theoretical: `invocation_slug` is
a brand-new field key, so no pre-existing items can have it set, and
no migration-time duplicates can exist. If a future custom collection
adopts the same field name, opting into per-collection uniqueness is
exactly the intended semantic of FieldDef.UniqueScope.)
Parent: PLAN-1377.
* fix(playbooks): map restore-path UNIQUE violations to 409 (TASK-1378)
Codex round 3: restoring an archived playbook can hit the partial
unique index on invocation_slug if a replacement item already claimed
the slug. Map UNIQUE constraint / duplicate key errors from RestoreItem
to HTTP 409 with a targeted message, matching the create + update paths.
Parent: PLAN-1377.
* fix(playbooks): map collab-snapshot UNIQUE violations to 409 (TASK-1378)
Codex round 4: the collab-snapshot PATCH branch under
`s.collab.UnderItemLock` ran its own UpdateItem call and fell through
to writeInternalError on any non-stale-snapshot error. A concurrent
edit racing the invocation_slug partial unique index would surface as
500 instead of 409. Mirror the main UpdateItem error mapping.
Codex's other round-4 finding — the partial unique index applying to
"every collection" — is not addressed because the index IS already
collection-scoped: `ON items(collection_id, json_extract(fields,
'$.invocation_slug'))`. Two items in different collections with the
same slug coexist; only same-collection duplicates conflict. Migration
duplicates are impossible because `invocation_slug` is a brand-new
field key with no pre-existing items setting it. A custom collection
that later adopts the same field name opts into per-collection
uniqueness, matching the FieldDef.UniqueScope="workspace_collection"
semantic.
Parent: PLAN-1377.
59 lines
2.5 KiB
SQL
59 lines
2.5 KiB
SQL
-- Add optional `invocation_slug` and `arguments` fields to the Playbooks
|
|
-- collection schema for every existing workspace. Foundational migration for
|
|
-- PLAN-1377 — playbooks become first-class invokable procedures.
|
|
--
|
|
-- invocation_slug: kebab-case identifier that enables /pad <slug> direct
|
|
-- invocation. Nullable — playbooks meant only for trigger-based auto-load
|
|
-- (e.g. trigger=on-release checklists) don't need a slug. Uniqueness is
|
|
-- enforced at the application layer (see internal/server/handlers_items.go
|
|
-- checkUniqueFields) since the JSON-stored fields map can't carry a SQL
|
|
-- UNIQUE constraint.
|
|
--
|
|
-- arguments: JSON array of {name, type, required, default, description}
|
|
-- specs declaring the playbook's argument contract.
|
|
--
|
|
-- Use JSON-aware mutation so customized schemas still receive the new fields
|
|
-- even if their field order differs or additional fields have been added.
|
|
UPDATE collections
|
|
SET schema = json_insert(
|
|
schema,
|
|
'$.fields[#]',
|
|
json('{"key":"invocation_slug","label":"Invocation slug","type":"text","pattern":"^[a-z0-9][a-z0-9-]*[a-z0-9]$","unique_scope":"workspace_collection"}')
|
|
)
|
|
WHERE slug = 'playbooks'
|
|
AND json_valid(schema)
|
|
AND json_type(schema, '$.fields') = 'array'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM json_each(collections.schema, '$.fields')
|
|
WHERE json_extract(json_each.value, '$.key') = 'invocation_slug'
|
|
);
|
|
|
|
UPDATE collections
|
|
SET schema = json_insert(
|
|
schema,
|
|
'$.fields[#]',
|
|
json('{"key":"arguments","label":"Arguments","type":"json"}')
|
|
)
|
|
WHERE slug = 'playbooks'
|
|
AND json_valid(schema)
|
|
AND json_type(schema, '$.fields') = 'array'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM json_each(collections.schema, '$.fields')
|
|
WHERE json_extract(json_each.value, '$.key') = 'arguments'
|
|
);
|
|
|
|
-- Atomically prevent two concurrent writers from inserting items with the
|
|
-- same invocation_slug within the same collection. The application-layer
|
|
-- pre-check in handlers_items.go gives users a friendly error message, but
|
|
-- it is a TOCTOU race on its own; this partial unique index is the actual
|
|
-- guard. NULL and empty-string slugs are excluded so the index only
|
|
-- constrains rows that opt into invocation routing. deleted_at IS NULL so
|
|
-- soft-deleted items don't block reuse of their old slug.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_invocation_slug_per_collection
|
|
ON items(collection_id, json_extract(fields, '$.invocation_slug'))
|
|
WHERE json_extract(fields, '$.invocation_slug') IS NOT NULL
|
|
AND json_extract(fields, '$.invocation_slug') != ''
|
|
AND deleted_at IS NULL;
|