Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e96459ce9 | ||
|
|
01905ec9ee | ||
|
|
30748f786a |
@@ -42,6 +42,111 @@ does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
|
|||||||
rule or a monthly rule could later be pinned to a month) nor extra
|
rule or a monthly rule could later be pinned to a month) nor extra
|
||||||
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
||||||
reject what the rule engine definitively cannot express now.
|
reject what the rule engine definitively cannot express now.
|
||||||
|
|
||||||
|
Presence pattern format: ``presence_schedules.pattern`` encodes the
|
||||||
|
whole N-week repeating cycle in one TEXT column as pipe-separated
|
||||||
|
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
|
||||||
|
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
|
||||||
|
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
|
||||||
|
week 1. An empty segment means the child is absent every day of that
|
||||||
|
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
|
||||||
|
absent every day of every week, which is distinct from having no
|
||||||
|
schedule row at all (a child with no schedule is present every day, per
|
||||||
|
the feature guardrails). Chosen over JSON or per-week rows to stay
|
||||||
|
consistent with the CSV ``weekday_set`` convention and fully checkable
|
||||||
|
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
|
||||||
|
custom functions: char-class GLOBs reject every character outside
|
||||||
|
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
|
||||||
|
(double comma, comma next to a pipe, leading/trailing comma) and
|
||||||
|
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
|
||||||
|
separated digits, which caps a segment at 7 elements — the GLOB cannot
|
||||||
|
match across a ``|``, so cross-segment boundaries stay safe; and the
|
||||||
|
segment count is pinned to ``cycle_length_weeks`` by
|
||||||
|
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
|
||||||
|
weekday_set this is not full CSV-set normalisation (duplicates like
|
||||||
|
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
|
||||||
|
the engine treats the segment as a set. A
|
||||||
|
one-row-per-``week_index`` normalization was considered and rejected:
|
||||||
|
the flat single-column encoding keeps the done-condition's table shape
|
||||||
|
and proved fully checkable with plain CHECKs.
|
||||||
|
|
||||||
|
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
|
||||||
|
common case, and a small cap keeps anchor arithmetic and the encoded
|
||||||
|
pattern human-readable. Revisit the cap — and re-run the pattern
|
||||||
|
validation matrix in tests — if a longer cycle is ever needed.
|
||||||
|
``anchor_date`` is an ISO date string pinning the cycle: the week index
|
||||||
|
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
|
||||||
|
computed by the presence engine (Feature 05) from anchor-date
|
||||||
|
arithmetic, never ISO week parity. Like other date columns it carries
|
||||||
|
no shape CHECK, matching the established date policy.
|
||||||
|
|
||||||
|
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
|
||||||
|
unlike ``schedule_rules.end_date`` — an override is always a concrete
|
||||||
|
date range; a single-day override stores the same date in both columns.
|
||||||
|
``is_present`` 0/1 marks the child absent/present for the whole range;
|
||||||
|
``note`` is optional free text.
|
||||||
|
|
||||||
|
Task instances: ``task_instances`` deliberately carries NO completion
|
||||||
|
status column. An instance's current state derives from the latest row
|
||||||
|
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||||
|
can insert instances without knowing anything about completion and the
|
||||||
|
append-only log stays the single source of truth. ``definition_id``,
|
||||||
|
``due_date`` and ``child_id`` snapshot the materialization-time facts
|
||||||
|
("why this instance exists"): reassignment and rule edits change future
|
||||||
|
instances only and never rewrite these rows. ``due_time`` snapshots
|
||||||
|
the definition's optional due time at generation for the same reason —
|
||||||
|
the instance keeps its generation-time value even if the definition is
|
||||||
|
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp
|
||||||
|
per the module timestamp policy (no DB default, so a generation batch
|
||||||
|
stamps one coherent time).
|
||||||
|
|
||||||
|
Uniqueness: the (definition_id, due_date) pair is declared as a
|
||||||
|
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
|
||||||
|
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
|
||||||
|
index (``sqlite_autoindex_task_instances_1``) — the required "unique
|
||||||
|
index prevents duplicate instances" — and the constraint form cannot be
|
||||||
|
silently dropped the way a standalone index can, so the duplicate door
|
||||||
|
stays shut. Idempotent materialization keys on exactly this pair. No
|
||||||
|
secondary indexes beyond it are declared yet: history and board queries
|
||||||
|
(Features 08/10/13) should justify their indexes as a later migration
|
||||||
|
once their query shapes exist.
|
||||||
|
|
||||||
|
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||||
|
module: children and definitions are deactivated, never deleted, so a
|
||||||
|
deletion that would orphan instances or events is a bug and must fail
|
||||||
|
loudly rather than cascade.
|
||||||
|
|
||||||
|
Completion events: ``completion_events`` is strictly append-only. That
|
||||||
|
property is enforced at the DAO layer — the completion-events DAO
|
||||||
|
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
|
||||||
|
by database triggers, matching the module's no-trigger policy: a RAISE
|
||||||
|
trigger would be the one exception to "pure CHECK, no triggers",
|
||||||
|
complicate the later migration runner, and still prove nothing (any
|
||||||
|
writer can run raw SQL). ``event_type`` is constrained to
|
||||||
|
``completed``/``uncompleted``; a ``missed`` row is intentionally not
|
||||||
|
representable — missed is a derived state announced on the HA event bus
|
||||||
|
(Feature 10), never an appended event.
|
||||||
|
|
||||||
|
``child_id`` is deliberately denormalized (it is also derivable via
|
||||||
|
``task_instances``): history reporting (Feature 13) filters and rates
|
||||||
|
per child directly against the log, and the log should stay
|
||||||
|
self-describing without joins.
|
||||||
|
|
||||||
|
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||||
|
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||||
|
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||||
|
NULL — the tapped child profile is already ``child_id``). A coherence
|
||||||
|
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
||||||
|
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
||||||
|
rejected; if an unattended system actor is ever needed, that is a
|
||||||
|
schema migration, not a silent widening.
|
||||||
|
|
||||||
|
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||||
|
``completed`` events — at completion time the due date is known and the
|
||||||
|
flag is decidable. For ``uncompleted`` events the flag is optional:
|
||||||
|
Feature 08 decides whether a reversal carries the reversed completion's
|
||||||
|
on-time flag or NULL, and the DDL deliberately accepts both rather than
|
||||||
|
guessing.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -130,11 +235,91 @@ SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
|
||||||
|
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
|
||||||
|
anchor_date TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL CHECK (
|
||||||
|
NOT pattern GLOB '*[^0-6,|]*'
|
||||||
|
AND NOT pattern GLOB '*[0-6][0-6]*'
|
||||||
|
AND NOT pattern GLOB '*,,*'
|
||||||
|
AND NOT pattern GLOB '*,|*'
|
||||||
|
AND NOT pattern GLOB '*|,*'
|
||||||
|
AND NOT pattern GLOB ',*'
|
||||||
|
AND NOT pattern GLOB '*,'
|
||||||
|
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
|
||||||
|
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS presence_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
|
||||||
|
note TEXT,
|
||||||
|
CHECK (end_date >= start_date)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS task_instances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
due_date TEXT NOT NULL,
|
||||||
|
due_time TEXT,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (definition_id, due_date)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||||
|
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||||
|
actor_user_id TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
was_on_time INTEGER CHECK (
|
||||||
|
was_on_time IS NULL
|
||||||
|
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||||
|
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||||
|
OR (event_type = 'uncompleted')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||||
*SCHEMA_V1_CHILDREN_DDL,
|
*SCHEMA_V1_CHILDREN_DDL,
|
||||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||||
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
||||||
|
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||||
|
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||||
|
*SCHEMA_V1_TASK_INSTANCES_DDL,
|
||||||
|
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1121
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user