mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 18:43:45 +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.
93 lines
4.2 KiB
Go
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"`
|
|
}
|