Files
pad/internal/models/collection.go
T
xarmian c38b3bf5cd feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378) (#517)
* 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.
2026-05-12 17:17:56 -04:00

93 lines
4.2 KiB
Go

package models
import "time"
type FieldDef struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, number, select, multi_select, date, checkbox, url, relation, json
Options []string `json:"options,omitempty"`
TerminalOptions []string `json:"terminal_options,omitempty"` // for select fields: which options represent a terminal/finalized state
Default any `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Computed bool `json:"computed,omitempty"`
Collection string `json:"collection,omitempty"` // for relation type
Suffix string `json:"suffix,omitempty"` // for number type display
Pattern string `json:"pattern,omitempty"` // optional ECMAScript-style regex applied to text values; empty = no pattern check
UniqueScope string `json:"unique_scope,omitempty"` // "workspace_collection" enforces uniqueness within a collection (non-empty values only); empty = no uniqueness
}
type CollectionSchema struct {
Fields []FieldDef `json:"fields"`
}
// QuickAction defines a prompt template that can be triggered from the UI.
type QuickAction struct {
Label string `json:"label"` // display label for the button
Prompt string `json:"prompt"` // prompt template with {ref}, {title}, {status}, etc.
Scope string `json:"scope"` // "item" or "collection"
Icon string `json:"icon,omitempty"` // optional emoji/icon
}
type CollectionSettings struct {
Layout string `json:"layout,omitempty"` // fields-primary, content-primary, balanced
DefaultView string `json:"default_view,omitempty"` // list, board, table
BoardGroupBy string `json:"board_group_by,omitempty"`
ListSortBy string `json:"list_sort_by,omitempty"`
ListGroupBy string `json:"list_group_by,omitempty"`
QuickActions []QuickAction `json:"quick_actions,omitempty"`
ContentTemplate string `json:"content_template,omitempty"` // markdown template for new items
}
type Collection struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
Description string `json:"description"`
Schema string `json:"schema"` // JSON string in DB, parsed via methods
Settings string `json:"settings"` // JSON string in DB
Prefix string `json:"prefix"`
SortOrder int `json:"sort_order"`
IsDefault bool `json:"is_default"`
IsSystem bool `json:"is_system"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Computed (not stored)
ItemCount int `json:"item_count"`
ActiveItemCount int `json:"active_item_count"`
}
type CollectionCreate struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
Prefix string `json:"prefix,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
Schema string `json:"schema,omitempty"`
Settings string `json:"settings,omitempty"`
IsDefault bool `json:"is_default,omitempty"`
IsSystem bool `json:"is_system,omitempty"`
}
// FieldMigration describes a bulk update to apply to existing items when
// a collection schema changes (e.g. renaming select options).
type FieldMigration struct {
Field string `json:"field"` // field key to migrate
RenameOptions map[string]string `json:"rename_options,omitempty"` // old_value → new_value
}
type CollectionUpdate struct {
Name *string `json:"name,omitempty"`
Prefix *string `json:"prefix,omitempty"`
Icon *string `json:"icon,omitempty"`
Description *string `json:"description,omitempty"`
Schema *string `json:"schema,omitempty"`
Settings *string `json:"settings,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
Migrations []FieldMigration `json:"migrations,omitempty"`
}