@ziprack/sdk 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.ts +11 -0
- package/dist/core/mock/MockRack.d.ts +15 -0
- package/dist/core/rack.d.ts +9 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/docs/README.md +3 -3
- package/dist/docs/applet-packages.md +18 -8
- package/dist/docs/auth.md +76 -0
- package/dist/docs/broadcast.md +1 -1
- package/dist/docs/conformance.md +14 -13
- package/dist/docs/database.md +253 -10
- package/dist/docs/extending.md +70 -4
- package/dist/docs/installation.md +9 -4
- package/dist/docs/introduction.md +7 -5
- package/dist/docs/manifest.md +32 -0
- package/dist/docs/mcp.md +15 -3
- package/dist/docs/protocol.md +42 -0
- package/dist/docs/publishing.md +87 -8
- package/dist/docs/reporting.md +11 -4
- package/dist/docs/sdk.md +28 -4
- package/dist/docs/security.md +53 -0
- package/dist/docs/storage.md +1 -1
- package/dist/docs/studio.md +1 -1
- package/dist/docs/versioning.md +16 -0
- package/dist/docs/ziprack-1-authoring.md +43 -7
- package/dist/index.js +41 -18
- package/dist/index.js.map +1 -1
- package/dist/{store-BXGon4iJ.js → store-DQbG_t2D.js} +15 -6
- package/dist/store-DQbG_t2D.js.map +1 -0
- package/dist/testing.js +1 -1
- package/dist/ziprack-sdk.iife.js +1 -1
- package/dist/ziprack-sdk.iife.js.map +1 -1
- package/package.json +1 -1
- package/dist/store-BXGon4iJ.js.map +0 -1
package/dist/auth/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { LaunchUser } from '../core/types';
|
|
|
4
4
|
* auth@1 — who the participant is and the profile attributes the Rack chose to
|
|
5
5
|
* release. Attributes the manifest requested but the Rack withheld are simply
|
|
6
6
|
* absent; degrade gracefully.
|
|
7
|
+
*
|
|
8
|
+
* Roles need no capability: every launch carries them.
|
|
7
9
|
*/
|
|
8
10
|
export declare class AuthApi {
|
|
9
11
|
private readonly link;
|
|
@@ -11,4 +13,13 @@ export declare class AuthApi {
|
|
|
11
13
|
user(): LaunchUser | null;
|
|
12
14
|
/** A released profile attribute, or the fallback when withheld. */
|
|
13
15
|
attribute<T = unknown>(key: string, fallback?: T | null): T | null;
|
|
16
|
+
/**
|
|
17
|
+
* The roles the participant holds: `member` or `guest`, `applet-author` and
|
|
18
|
+
* `rack-administrator` when they apply, and the manifest's own roles the
|
|
19
|
+
* Rack gives them. Read at launch; a change applies from the next launch.
|
|
20
|
+
*/
|
|
21
|
+
roles(): string[];
|
|
22
|
+
hasRole(role: string): boolean;
|
|
23
|
+
/** Whether the participant is a guest rather than a signed-in Rack user. */
|
|
24
|
+
isGuest(): boolean;
|
|
14
25
|
}
|
|
@@ -45,6 +45,12 @@ export interface MockManifest {
|
|
|
45
45
|
channels?: string[];
|
|
46
46
|
mock_mode?: 'first_in' | 'opt_in';
|
|
47
47
|
};
|
|
48
|
+
roles?: Array<{
|
|
49
|
+
name: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
grant?: 'everyone' | 'members' | 'guests' | 'assigned';
|
|
53
|
+
}>;
|
|
48
54
|
[key: string]: unknown;
|
|
49
55
|
}
|
|
50
56
|
export type AiMockHandler = (vars: JsonObject, mode: 'analysis' | 'chat', message?: string) => unknown | Promise<unknown>;
|
|
@@ -56,6 +62,10 @@ export interface MockRackOptions {
|
|
|
56
62
|
participantName?: string;
|
|
57
63
|
/** Force the mock room role instead of deriving it from the assignment mode. */
|
|
58
64
|
role?: RoomRole;
|
|
65
|
+
/** Launch as a guest: `guest` instead of `member`, and the guest grants. */
|
|
66
|
+
guest?: boolean;
|
|
67
|
+
/** Roles to hold on top of the grants, as if the Rack had assigned them (any name, built-in ones included). */
|
|
68
|
+
roles?: string[];
|
|
59
69
|
}
|
|
60
70
|
export type MockOutcome = AckResult | {
|
|
61
71
|
stream: AsyncIterable<JsonObject>;
|
|
@@ -93,6 +103,11 @@ export declare class MockRack {
|
|
|
93
103
|
onEvent(listener: (event: RackEvent) => void): () => void;
|
|
94
104
|
pushEvent(event: RackEvent): void;
|
|
95
105
|
launchData(nameHint?: string): LaunchData;
|
|
106
|
+
/**
|
|
107
|
+
* What a Rack would resolve: member or guest, the manifest's grants, and
|
|
108
|
+
* any roles the options say were assigned.
|
|
109
|
+
*/
|
|
110
|
+
roles(): string[];
|
|
96
111
|
mockAi(promptId: string, handler: AiMockHandler): void;
|
|
97
112
|
dispose(): void;
|
|
98
113
|
handle(type: string, payload: JsonObject): Promise<MockOutcome>;
|
package/dist/core/rack.d.ts
CHANGED
|
@@ -66,3 +66,12 @@ export declare class RackClient {
|
|
|
66
66
|
private flushQueue;
|
|
67
67
|
private mockRack;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Mock-mode identity from the page URL, so an author can try each role:
|
|
71
|
+
* `?ziprack-roles=dungeon-master,applet-author` adds assigned roles and
|
|
72
|
+
* `?ziprack-guest` launches as a guest.
|
|
73
|
+
*/
|
|
74
|
+
export declare function mockIdentityFromUrl(search?: string): {
|
|
75
|
+
roles?: string[];
|
|
76
|
+
guest?: boolean;
|
|
77
|
+
};
|
package/dist/core/types.d.ts
CHANGED
|
@@ -86,8 +86,15 @@ export interface LaunchData {
|
|
|
86
86
|
applet: LaunchApplet;
|
|
87
87
|
capabilities: string[];
|
|
88
88
|
media: Record<string, MediaEmbed>;
|
|
89
|
+
/**
|
|
90
|
+
* The roles the participant holds in this applet: `member` or `guest`,
|
|
91
|
+
* `applet-author` and `rack-administrator` when they apply, plus the
|
|
92
|
+
* manifest's own roles the Rack gives them. Absent on Racks before v1.8.
|
|
93
|
+
*/
|
|
94
|
+
roles?: string[];
|
|
89
95
|
auth?: {
|
|
90
96
|
user: LaunchUser;
|
|
97
|
+
withheld?: string[];
|
|
91
98
|
};
|
|
92
99
|
attempt?: LaunchAttempt;
|
|
93
100
|
activity?: {
|
package/dist/docs/README.md
CHANGED
|
@@ -24,10 +24,10 @@ These docs cover both halves. If you are building an applet, start with
|
|
|
24
24
|
|
|
25
25
|
## Capabilities
|
|
26
26
|
|
|
27
|
-
- [Authentication](auth.md) — `auth@1
|
|
27
|
+
- [Authentication](auth.md) — `auth@1`, and roles
|
|
28
28
|
- [Attempts](attempts.md) — `attempt@1`
|
|
29
29
|
- [Activity](activity.md) — `activity@1`
|
|
30
|
-
- [Database](database.md) — `db@1
|
|
30
|
+
- [Database](database.md) — `db@1`, data across versions, migrations, snapshots
|
|
31
31
|
- [Storage](storage.md) — `storage@1`
|
|
32
32
|
- [AI](ai.md) — `ai@1`
|
|
33
33
|
- [Broadcast](broadcast.md) — `broadcast@1`
|
|
@@ -37,7 +37,7 @@ These docs cover both halves. If you are building an applet, start with
|
|
|
37
37
|
|
|
38
38
|
- [Installation](installation.md) — composing a Rack from capability packages
|
|
39
39
|
- [Security](security.md) — the sandbox, the CSP, the threats it does not cover
|
|
40
|
-
- [Publishing](publishing.md) — install, capability diff, review queue, promotion, kill switch
|
|
40
|
+
- [Publishing](publishing.md) — install, capability diff, review queue, promotion and the data cutover, kill switch
|
|
41
41
|
- [Reporting](reporting.md) — the admin API, CSV export, metrics
|
|
42
42
|
- [Who may do what](reporting.md#who-may-do-what) — `AppletAccess`, abilities, applet owners
|
|
43
43
|
- [Conformance](conformance.md) — proving a deployment is still a Rack
|
|
@@ -17,10 +17,16 @@ An applet ships as a single zip, named for the applet and the version it contain
|
|
|
17
17
|
├── manifest.json required, at the zip root
|
|
18
18
|
├── sbom.json required — CycloneDX, or package-lock.json
|
|
19
19
|
├── index.html the entry (configurable via manifest.entry)
|
|
20
|
+
├── migrations/… optional — data migrations, one ES module per file
|
|
20
21
|
└── assets/… everything the entry references, by RELATIVE path
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
`migrations/` is the one other directory the Rack reads: the [data migrations](database.md#data-migrations)
|
|
25
|
+
that reshape existing records when the version is promoted. Its files are ordinary `.js` or `.mjs`
|
|
26
|
+
files to the installer, named `YYYY_MM_DD_HHMMSS_name.js` and sitting directly inside it; any other
|
|
27
|
+
name there refuses the package.
|
|
28
|
+
|
|
29
|
+
Apart from those, nothing is special. The Rack extracts the archive, keeps the file tree intact, and serves it
|
|
24
30
|
through a token-prefixed path. There is no build step on the server, no server-side templating, no
|
|
25
31
|
framework to satisfy — whatever your bundler emits is what runs.
|
|
26
32
|
|
|
@@ -39,10 +45,11 @@ than skipping a bad file:
|
|
|
39
45
|
- **The slug must match** the applet you are uploading to, and it is immutable once created.
|
|
40
46
|
- **A `version` string uploads once per applet.** Bump it every time, including during testing.
|
|
41
47
|
|
|
42
|
-
Installation runs inside a transaction: the version row is created,
|
|
43
|
-
`onInstall()` hook runs — this is where `db@1`
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
Installation runs inside a transaction: the version row is created, the files are extracted, and
|
|
49
|
+
then every declared capability's `onInstall()` hook runs — this is where `db@1` checks seed files
|
|
50
|
+
and migration names and registers new collection names. No tables are created; records are only
|
|
51
|
+
touched at [promotion](publishing.md#the-data-cutover). A failure anywhere rolls the whole thing
|
|
52
|
+
back and deletes the extracted directory.
|
|
46
53
|
|
|
47
54
|
<a name="the-starter"></a>
|
|
48
55
|
## The Starter
|
|
@@ -97,15 +104,18 @@ having, because negligence is the common case.
|
|
|
97
104
|
`npm run package` in the starter does the whole job:
|
|
98
105
|
|
|
99
106
|
```js
|
|
100
|
-
// scripts/package.
|
|
107
|
+
// scripts/package.mjs — abbreviated
|
|
101
108
|
const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
|
|
102
109
|
|
|
103
110
|
execSync('npm sbom --sbom-format cyclonedx --omit dev', { cwd: lockDir }) // → dist/sbom.json
|
|
111
|
+
cpSync('migrations', 'dist/migrations', { recursive: true }); // when present
|
|
104
112
|
execSync(`zip -r -X "${zipPath}" . -x ".*" -x "__MACOSX*"`, { cwd: 'dist' });
|
|
105
113
|
```
|
|
106
114
|
|
|
107
|
-
|
|
108
|
-
dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway
|
|
115
|
+
Three details worth keeping if you write your own: `-X` drops extra file attributes, excluding
|
|
116
|
+
dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway, and a
|
|
117
|
+
top-level `migrations/` is copied in as written — never bundled, because the Rack imports each file
|
|
118
|
+
on its own.
|
|
109
119
|
|
|
110
120
|
Upload the result through the admin API, or from the deployment:
|
|
111
121
|
|
package/dist/docs/auth.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
- [Declaring Attributes](#declaring-attributes)
|
|
5
5
|
- [Reading the Participant](#reading-the-participant)
|
|
6
6
|
- [Degrading Gracefully](#degrading-gracefully)
|
|
7
|
+
- [Roles](#roles)
|
|
7
8
|
- [Rack Side](#rack-side)
|
|
8
9
|
|
|
9
10
|
<a name="introduction"></a>
|
|
@@ -86,6 +87,59 @@ Design the withheld path first and treat personalisation as an enhancement. An a
|
|
|
86
87
|
`Hello, undefined` when an attribute is missing has failed validation of a kind the Rack cannot
|
|
87
88
|
perform for you.
|
|
88
89
|
|
|
90
|
+
`launch.auth.withheld` lists the requested keys this deployment never releases, which is useful in
|
|
91
|
+
development to tell "not configured here" from "not set for this person". It says nothing about any
|
|
92
|
+
one participant.
|
|
93
|
+
|
|
94
|
+
<a name="roles"></a>
|
|
95
|
+
## Roles
|
|
96
|
+
|
|
97
|
+
Roles answer a different question: *what part does this person play in this applet?* They need no
|
|
98
|
+
capability. Every launch carries the participant's roles, resolved by the Rack:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
Rack.auth.roles(); // ['member', 'applet-author', 'judge']
|
|
102
|
+
Rack.auth.hasRole('judge'); // true
|
|
103
|
+
Rack.auth.isGuest(); // false
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Every Rack resolves four built-in roles for every applet:
|
|
107
|
+
|
|
108
|
+
| Role | Held by |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `member` | A signed-in Rack user. |
|
|
111
|
+
| `guest` | A cookie-keyed [guest](installation.md#participants-and-guests). Exactly one of `member` and `guest` is present. |
|
|
112
|
+
| `applet-author` | The applet's owner. |
|
|
113
|
+
| `rack-administrator` | A user the `ziprack.admin` gate allows. |
|
|
114
|
+
|
|
115
|
+
An applet adds its own in the manifest's [`roles`](manifest.md#roles) block. Each declared role has
|
|
116
|
+
a `grant`, which says who holds it before the Rack assigns anyone:
|
|
117
|
+
|
|
118
|
+
| `grant` | Held by |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `everyone` | Every participant. |
|
|
121
|
+
| `members` | Every signed-in participant. |
|
|
122
|
+
| `guests` | Every guest. |
|
|
123
|
+
| `assigned` | Nobody, until the Rack assigns it. The default. |
|
|
124
|
+
|
|
125
|
+
The Rack then **assigns** declared roles to signed-in participants, per applet: to a Rack user, to
|
|
126
|
+
everyone holding one of the Rack's own roles (an administrator, say), to an email address, or to
|
|
127
|
+
every address at a domain. An assignment adds to the grant; it never takes the role away from
|
|
128
|
+
someone the grant gives it to. Guests receive grants only, never assignments.
|
|
129
|
+
|
|
130
|
+
Roles are resolved at launch, so an assignment applies from the participant's next launch. An
|
|
131
|
+
assignment to a role the current version no longer declares is kept, but ignored until a version
|
|
132
|
+
declares the role again.
|
|
133
|
+
|
|
134
|
+
> **Roles inform your code; nothing enforces them yet.** No capability checks a role server-side:
|
|
135
|
+
> a `db@1` collection's `permissions` and a broadcast's `facilitator_events` know nothing about
|
|
136
|
+
> them. Use roles to decide what to render and which actions to offer. Do not rely on them to keep a
|
|
137
|
+
> determined participant out of data the manifest's scopes and permissions already expose.
|
|
138
|
+
|
|
139
|
+
Declared roles are not the room's `facilitator` and `participant`. Those belong to
|
|
140
|
+
[`broadcast@1`](broadcast.md#the-facilitator-role), change during a session, and are enforced by
|
|
141
|
+
the Rack.
|
|
142
|
+
|
|
89
143
|
<a name="rack-side"></a>
|
|
90
144
|
## Rack Side
|
|
91
145
|
|
|
@@ -104,3 +158,25 @@ Admin endpoints:
|
|
|
104
158
|
To source attributes from SSO claims instead, bind
|
|
105
159
|
`ZipRack\Auth\Contracts\ProfileAttributeSource`; `ClaimMapSource` is the default implementation and
|
|
106
160
|
a reasonable starting point.
|
|
161
|
+
|
|
162
|
+
Role assignments are core, so they work whether or not `ziprack/auth` is installed:
|
|
163
|
+
|
|
164
|
+
| Route | Purpose |
|
|
165
|
+
|---|---|
|
|
166
|
+
| `GET /ziprack/admin/applets/{applet}/roles` | `roles` (the current version's, each with its `assignments`), `undeclared` (assignments to roles it no longer declares), `built_in`, `rack_roles`. |
|
|
167
|
+
| `PUT /ziprack/admin/applets/{applet}/roles/{role}` | Replace one role's assignments: `{ assignments: [{ type, value }] }`. |
|
|
168
|
+
|
|
169
|
+
| `type` | `value` |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `user` | A Rack user's id. |
|
|
172
|
+
| `rack_role` | A key from `RackRoles::all()`. |
|
|
173
|
+
| `email` | An address, compared case-insensitively. |
|
|
174
|
+
| `domain` | `example.com` (a leading `@` is dropped). Matches that domain exactly, not its subdomains. |
|
|
175
|
+
|
|
176
|
+
The Rack's own roles come from `ZipRack\Core\Contracts\RackRoles`: `all()` returns every key and
|
|
177
|
+
label, `of($user)` the keys one user holds. The default, `GateRackRoles`, knows one, `administrator`,
|
|
178
|
+
from the `ziprack.admin` gate. A Rack with its own roles binds its implementation:
|
|
179
|
+
|
|
180
|
+
```php
|
|
181
|
+
$this->app->singleton(RackRoles::class, MyRackRoles::class);
|
|
182
|
+
```
|
package/dist/docs/broadcast.md
CHANGED
|
@@ -26,7 +26,7 @@ Transport is Laravel Reverb. That is a deployment concern: nothing in the applet
|
|
|
26
26
|
<a name="rooms"></a>
|
|
27
27
|
## Rooms
|
|
28
28
|
|
|
29
|
-
A **room** is an applet
|
|
29
|
+
A **room** is an applet plus its members. Every member has exactly one of two roles —
|
|
30
30
|
`participant` or `facilitator` — and membership is orthogonal to attempts: a room may contain
|
|
31
31
|
attempts (each person running their own inside a shared session) or none at all.
|
|
32
32
|
|
package/dist/docs/conformance.md
CHANGED
|
@@ -28,19 +28,18 @@ The suite runs inside a transaction that is rolled back, so it is safe against a
|
|
|
28
28
|
check builds its own fixture — a temporary applet, a version, a package — and cleans up after itself.
|
|
29
29
|
|
|
30
30
|
```
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
✓ attempts are pinned
|
|
40
|
-
✓ statements are append-only
|
|
41
|
-
✓ records are scoped and validated
|
|
31
|
+
PASS A manifest missing required fields fails validation with the expected error
|
|
32
|
+
PASS The installer rejects zips containing traversal, absolute paths, symlinks or disallowed files
|
|
33
|
+
PASS An applet requesting a capability this Rack does not implement is refused at install
|
|
34
|
+
…
|
|
35
|
+
PASS db@1: records are schema-validated, quota-limited and invisible across scopes
|
|
36
|
+
PASS db@1: applet data carries over to a new version, and a version that would lose some is not promoted silently
|
|
37
|
+
|
|
38
|
+
18 checks, 0 failed.
|
|
42
39
|
```
|
|
43
40
|
|
|
41
|
+
A failing check prints `FAIL`, its name and the reason, and the command exits non-zero.
|
|
42
|
+
|
|
44
43
|
<a name="what-it-checks"></a>
|
|
45
44
|
## What It Checks
|
|
46
45
|
|
|
@@ -55,17 +54,19 @@ Core checks run on every Rack:
|
|
|
55
54
|
| `CspIsTightened` | The response CSP contains the required directives, and `img-src` does not contain `https:`. |
|
|
56
55
|
| `RetiredVersionIsGone` | A retired version can no longer be launched. |
|
|
57
56
|
| `PromotionRequiresReview` | A version adding a capability cannot be promoted unapproved. |
|
|
57
|
+
| `RolesAreDeclaredAndResolved` | A manifest declaring a built-in role fails validation, and a guest launch resolves exactly `guest` plus the roles granted to everyone and to guests. |
|
|
58
58
|
|
|
59
59
|
Each installed capability contributes its own, so the suite grows with the Rack:
|
|
60
60
|
|
|
61
61
|
| Check | Capability |
|
|
62
62
|
|---|---|
|
|
63
|
-
| `CompletedAttemptIsFrozen`, `AttemptsArePinned` | `attempt` |
|
|
63
|
+
| `CompletedAttemptIsFrozen`, `AttemptsArePinned`, `PreviewAttemptsAreSeparate` | `attempt` |
|
|
64
64
|
| `StatementsAreAppendOnly` | `activity` |
|
|
65
65
|
| `RecordsAreScopedAndValidated` | `db` |
|
|
66
|
+
| `DataSurvivesPromotion` | `db` — applet records carry over to a new current version, and a version whose schema rejects them is refused with `DataCutoverRequired` rather than promoted |
|
|
66
67
|
| `UploadsAreScopedAndTyped` | `storage` |
|
|
67
68
|
| `AiBudgetsAreEnforced` | `ai` |
|
|
68
|
-
| `FacilitatorEventsAreEnforced` | `broadcast` |
|
|
69
|
+
| `FacilitatorEventsAreEnforced`, `InviteNeedsAnOpenRoom` | `broadcast` |
|
|
69
70
|
|
|
70
71
|
<a name="in-ci"></a>
|
|
71
72
|
## In CI
|
package/dist/docs/database.md
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
- [Permissions](#permissions)
|
|
9
9
|
- [Quotas](#quotas)
|
|
10
10
|
- [Seeding](#seeding)
|
|
11
|
+
- [Data Across Versions](#data-across-versions)
|
|
12
|
+
- [Data Migrations](#data-migrations)
|
|
13
|
+
- [Snapshots](#snapshots)
|
|
11
14
|
- [How It Is Stored](#how-it-is-stored)
|
|
15
|
+
- [Rack Side](#rack-side)
|
|
12
16
|
|
|
13
17
|
<a name="introduction"></a>
|
|
14
18
|
## Introduction
|
|
@@ -67,11 +71,19 @@ what happens to it when an attempt ends.
|
|
|
67
71
|
| `attempt` | One attempt | **Frozen when the attempt completes**, alongside `state` | `attempt@1` |
|
|
68
72
|
| `user` | One participant | Outlives any attempt | — |
|
|
69
73
|
| `room` | Everyone in the room | The room | `broadcast@1` |
|
|
70
|
-
| `applet` | Everyone
|
|
74
|
+
| `applet` | Everyone using the applet, on every version | The applet. Carries over every promotion. Seed data, shared catalogs | — |
|
|
71
75
|
|
|
72
76
|
A session that has no attempt or no room cannot touch a collection scoped to one; the call is
|
|
73
77
|
refused with `forbidden` rather than silently falling back to something else.
|
|
74
78
|
|
|
79
|
+
`user`, `room` and `applet` records belong to the applet, not to a version, so they are still there
|
|
80
|
+
after you promote a new one — see [Data Across Versions](#data-across-versions). `attempt` records
|
|
81
|
+
stay with their attempt, and so with the version it ran on.
|
|
82
|
+
|
|
83
|
+
A session reads and writes a collection through the definition of the version it launched on. An
|
|
84
|
+
attempt pinned to an earlier version keeps that version's schema, indexes and quota until it
|
|
85
|
+
completes, even after a later version changed them.
|
|
86
|
+
|
|
75
87
|
<a name="reading-and-writing"></a>
|
|
76
88
|
## Reading and Writing
|
|
77
89
|
|
|
@@ -183,33 +195,230 @@ The seed file is a JSON array of records, each one a document exactly as `create
|
|
|
183
195
|
]
|
|
184
196
|
```
|
|
185
197
|
|
|
186
|
-
The
|
|
187
|
-
|
|
198
|
+
The seed is **checked at install and loaded at promotion**. At install the Rack holds every record
|
|
199
|
+
to the rules a write would face: it must be an object, it must match the schema, it must fit
|
|
188
200
|
`max_bytes`, and the file must fit `max_records`. One bad record refuses the whole package, and the
|
|
189
201
|
error names the record's index and the failing path. A package with a bad seed never installs.
|
|
190
202
|
|
|
203
|
+
When the version is promoted, the seed loads into the collection **only if its live dataset is
|
|
204
|
+
empty**. A seed is the starting data, never a reset: once the applet has records, a later version's
|
|
205
|
+
seed file is not loaded, even if it changed. The promotion plan says so. To change records that
|
|
206
|
+
already exist, ship a [data migration](#data-migrations).
|
|
207
|
+
|
|
191
208
|
Things to know:
|
|
192
209
|
|
|
193
210
|
- **Only `applet` scope.** No room, participant or attempt exists at install, so nothing else has
|
|
194
211
|
a scope to seed into.
|
|
195
|
-
- **
|
|
196
|
-
|
|
212
|
+
- **Loaded once, then the data is yours.** Records carry over every promotion. If the collection is
|
|
213
|
+
emptied (preview records do not count), the next promotion loads the seed again.
|
|
197
214
|
- **Seeded records have no owner.** `owner_id` is `null`, so under the default `owner` permission
|
|
198
215
|
participants can read them but cannot change them. Declare `members` or `facilitator` if they
|
|
199
216
|
should be editable.
|
|
200
|
-
- **No `RecordChanged` events.**
|
|
217
|
+
- **No `RecordChanged` events.** The seed loads inside the promotion, before any session runs the
|
|
218
|
+
new version.
|
|
201
219
|
- **The quota still applies.** A seed is capped at 5000 records (`max_records_per_scope`), and so
|
|
202
220
|
is everything later written to that scope.
|
|
203
221
|
- **Not in the mock Rack.** The SDK's `MockRack` does not load seed files. In local development,
|
|
204
222
|
create the records from your dev harness.
|
|
205
223
|
|
|
224
|
+
<a name="data-across-versions"></a>
|
|
225
|
+
## Data Across Versions
|
|
226
|
+
|
|
227
|
+
Promoting a version carries the applet's data over to it. `user`, `room` and `applet` records are
|
|
228
|
+
kept and checked against the new version's collections; `attempt` records are left alone, in the
|
|
229
|
+
shape their attempt wrote them. Nothing about your data changes at install: a new version's
|
|
230
|
+
collections are checked, and names the applet has never had are registered empty, but the live
|
|
231
|
+
version keeps reading what it always read until promotion.
|
|
232
|
+
|
|
233
|
+
At promotion the Rack plans the cutover. For each declared collection it works out whether it is
|
|
234
|
+
`added`, `removed`, `restored`, `changed` or `unchanged`, describes the structural diff (a new
|
|
235
|
+
required property, a narrowed enum, a changed quota), and — when no migration runs — validates
|
|
236
|
+
every existing record against the new schema and quota. A promotion that would leave records the
|
|
237
|
+
new version rejects, strand a collection that holds records, or roll migrations back is not done
|
|
238
|
+
silently: it is refused until someone confirms, and the refusal carries the plan. See
|
|
239
|
+
[Promotion](publishing.md#promotion) for how that looks from the admin API, the CLI and MCP.
|
|
240
|
+
|
|
241
|
+
| The new version… | What happens at promotion |
|
|
242
|
+
|---|---|
|
|
243
|
+
| Changes nothing about its collections | Promotes. Records are untouched; no snapshot is taken. |
|
|
244
|
+
| Makes a compatible change: a new optional property, a wider enum, a new index, a new collection | Promotes. Existing records validate, so they carry over as they are. A snapshot is taken first when a collection holding records changed. New collections start empty, or with their seed. |
|
|
245
|
+
| Ships a [migration](#data-migrations) that has not run on this applet | Must be promoted from the Rack's UI, which runs the migrations in the browser, checks the result, then commits it. The CLI and MCP refuse it (`migration_required`). |
|
|
246
|
+
| Makes an incompatible change with no migration | Refused with `data_loss`, naming how many records the new schema rejects, with samples. Once confirmed, rejected records are deleted (`invalid: discard`, the default) or kept as they are (`keep`). A kept record fails validation on its next update until it is fixed. |
|
|
247
|
+
| Drops a collection that holds records | Refused with `data_loss`. Once confirmed, the collection is set aside with its records; `ziprack:db:prune` deletes them. A later version that declares it again gets them back. |
|
|
248
|
+
| Changes a collection's `scope` | Refused **at install**: `Collection [x] cannot change scope from [room] to [applet]; use a new collection name.` A migration cannot move records between scopes either. |
|
|
249
|
+
| Lowers `max_records` below what a scope holds, or `max_bytes` below a record's size | Refused with `data_loss`. Once confirmed, oversize records count as rejected; a scope over its new record quota keeps its records but refuses `create()` with `quota_exceeded` until it is back under. |
|
|
250
|
+
| Ships a changed seed file for a collection that has records | Promotes. The seed is not loaded, and the plan notes it. Use a migration. |
|
|
251
|
+
| Is an earlier version that lacks migrations already run (a rollback) | Those migrations run `down`, newest first, from the source the Rack stored when they ran up — in the browser, like any migration, and flagged as a rollback. A migration with no `down` stops it; restore the [snapshot](#snapshots) taken before it ran instead. An earlier version with no migrations involved is treated like any other change. |
|
|
252
|
+
| Is the applet's first | Nothing to carry over. Its migrations are recorded as applied without running, and the seed loads. |
|
|
253
|
+
|
|
254
|
+
Uploading without activating changes nothing about the data; the plan is made when someone
|
|
255
|
+
promotes. An upload *with* activate tries the promotion, and if the cutover needs a decision the
|
|
256
|
+
version stays installed but not current.
|
|
257
|
+
|
|
258
|
+
Two more rules apply to every cutover:
|
|
259
|
+
|
|
260
|
+
- **Preview records are dropped where shapes change.** A cutover that runs migrations deletes every
|
|
261
|
+
[preview](attempts.md#preview-runs) record of the applet; one that does not deletes the preview
|
|
262
|
+
records of the collections whose definition changed. Migrations never see preview records.
|
|
263
|
+
- **Pinned attempts keep the old shape.** An in-progress attempt on the outgoing version reads and
|
|
264
|
+
writes `user`, `room` and `applet` records through that version's definition until it completes.
|
|
265
|
+
After a cutover that reshapes them, it can still write records in the old shape. If your applet is
|
|
266
|
+
pinned, write code that tolerates both, or reshape only what in-progress attempts do not write.
|
|
267
|
+
|
|
268
|
+
<a name="data-migrations"></a>
|
|
269
|
+
## Data Migrations
|
|
270
|
+
|
|
271
|
+
When a new version needs existing records in a new shape — a renamed property, a split collection,
|
|
272
|
+
a value back-filled from another — ship a data migration. A migration is an ES module in a
|
|
273
|
+
`migrations/` directory at the zip root:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
my-applet-2.0.0.zip
|
|
277
|
+
├── manifest.json
|
|
278
|
+
├── migrations/
|
|
279
|
+
│ ├── 2026_09_01_090000_add_difficulty.js
|
|
280
|
+
│ └── 2026_10_09_120000_split_archived_ideas.js
|
|
281
|
+
└── …
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
| Rule | |
|
|
285
|
+
|---|---|
|
|
286
|
+
| Name | `YYYY_MM_DD_HHMMSS_name.js` (or `.mjs`), lowercase, directly inside `migrations/`. Anything else refuses the package at install. |
|
|
287
|
+
| Order | Name order. The timestamp prefix is the order. |
|
|
288
|
+
| Cumulative | **Keep every migration you have ever shipped.** The Rack runs the ones this applet has not run yet, so any earlier version can be brought forward. |
|
|
289
|
+
| Self-contained | Each file is imported on its own, with no network. Do not import other files or packages. The starter copies `migrations/` into the zip as written, unbundled. |
|
|
290
|
+
| Exports | `up(db, ctx)`, and `down(db, ctx)` if the change can be undone. Either as named exports or on the default export. |
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
// migrations/2026_09_01_090000_add_difficulty.js
|
|
294
|
+
export function up(db, ctx) {
|
|
295
|
+
for (const question of db.questions) {
|
|
296
|
+
question.data.difficulty ??= 'medium';
|
|
297
|
+
}
|
|
298
|
+
ctx.log(`set a difficulty on ${db.questions.length} questions`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function down(db) {
|
|
302
|
+
for (const question of db.questions) {
|
|
303
|
+
delete question.data.difficulty;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`db` holds the applet's carried records, by collection name: every `user`, `room` and `applet`
|
|
309
|
+
collection, including ones the outgoing version no longer declares. A record is:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
interface MigrationRecord {
|
|
313
|
+
id: string;
|
|
314
|
+
scope_id: number; // the room or participant; 0 for applet scope
|
|
315
|
+
owner_id: number | null;
|
|
316
|
+
data: object; // the document, as create() took it
|
|
317
|
+
created_at: string;
|
|
318
|
+
updated_at: string;
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Mutate it in place, or return `{ collection: records[] }` for the collections you replace. A
|
|
323
|
+
collection you return replaces that collection; one you leave out is kept unchanged, so its records
|
|
324
|
+
must already fit the target version.
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
// migrations/2026_10_09_120000_split_archived_ideas.js
|
|
328
|
+
export default {
|
|
329
|
+
up(db) {
|
|
330
|
+
return {
|
|
331
|
+
ideas: db.ideas.filter((idea) => idea.data.status !== 'archived'),
|
|
332
|
+
archived_ideas: db.ideas.filter((idea) => idea.data.status === 'archived'),
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
down(db) {
|
|
337
|
+
return { ideas: [...db.ideas, ...db.archived_ideas] };
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
A record that appears under another collection's name moves there, keeping its id. To add a record,
|
|
343
|
+
leave out `id`; it is given a new one. A collection the target version does not declare cannot be
|
|
344
|
+
written to: returning it is ignored.
|
|
345
|
+
|
|
346
|
+
`ctx` is `{ direction, applet, from, to, scopes, log }`: `'up'` or `'down'`, the applet slug, the
|
|
347
|
+
outgoing and target version strings (`from` is `null` when nothing was current), each collection's
|
|
348
|
+
scope by name, and `log(...)` for lines kept with the snapshot. `console.log`, `info`, `warn` and
|
|
349
|
+
`error` are captured the same way.
|
|
350
|
+
|
|
351
|
+
**The Rack checks what comes back.** Every record the cutover leaves in place, returned or kept,
|
|
352
|
+
must match the target version's schema and fit its quota. A record cannot change its `scope_id` or `owner_id`, and can move only to
|
|
353
|
+
a collection with the same scope. A new record in a `user` or `room` collection needs the
|
|
354
|
+
`scope_id` of a participant or room that already has records, and its `owner_id` must be `null` or
|
|
355
|
+
someone who already owns one. Records missing from a returned collection are deleted. If any check
|
|
356
|
+
fails, nothing is written and the errors name the collection, the record and the problem. These are
|
|
357
|
+
checks on structure, not on which participant's values end up in which record: migrations see every
|
|
358
|
+
scope's data and are [privileged code](security.md#the-migration-runner).
|
|
359
|
+
|
|
360
|
+
Migrations run in the browser of whoever promotes, in a sandboxed frame with no network access
|
|
361
|
+
(see [Security](security.md#the-migration-runner)), one after another, 30 seconds each by default.
|
|
362
|
+
A migration that throws, times out, lacks the function it needs, or returns anything other than
|
|
363
|
+
`{ collection: records[] }` stops the run, and nothing is written. If the applet's data changes
|
|
364
|
+
while they run, the promotion is refused with `data_changed` and they run again.
|
|
365
|
+
|
|
366
|
+
The Rack keeps a ledger of which migrations have run on each applet, with each one's source. Going
|
|
367
|
+
forward, pending `up`s run in name order. Promoting a version that lacks some applied migrations
|
|
368
|
+
runs their `down`s in reverse, from the stored source, so a rollback works even after a later
|
|
369
|
+
package dropped the file. A migration with no `down` cannot be rolled back; the Rack says so, and
|
|
370
|
+
you restore the snapshot taken before it ran.
|
|
371
|
+
|
|
372
|
+
`attempt` records are never passed in: they stay in the shape their attempt wrote them. Preview
|
|
373
|
+
records are not passed in either.
|
|
374
|
+
|
|
375
|
+
Migrations are plain functions over plain arrays, so test them like any other: import the module,
|
|
376
|
+
call `up` on a fixture, assert on the result. The mock Rack does not run them.
|
|
377
|
+
|
|
378
|
+
<a name="snapshots"></a>
|
|
379
|
+
## Snapshots
|
|
380
|
+
|
|
381
|
+
Before every cutover that changes data, the Rack copies the applet's entire dataset: every record
|
|
382
|
+
in every collection and scope, preview and set-aside ones included, the collection registry, the
|
|
383
|
+
migration ledger and the applet's [role](auth.md#roles) assignments. The copy is written before the
|
|
384
|
+
promotion's transaction opens and marked applied if the cutover commits, so a migration that
|
|
385
|
+
wrecks the data can always be undone.
|
|
386
|
+
|
|
387
|
+
The transaction then locks the applet's collections, which holds record writes off until it
|
|
388
|
+
commits, and checks the data is still what the cutover was planned and snapshotted from. A write
|
|
389
|
+
that landed in between refuses the promotion with `data_changed`, before anything is deleted, so a
|
|
390
|
+
cutover never removes a record its snapshot does not hold. Likewise, a promotion refuses with
|
|
391
|
+
`version_changed` if another one committed while it was being prepared.
|
|
392
|
+
|
|
393
|
+
Snapshots are also taken before every restore, so a restore can itself be undone, and on request:
|
|
394
|
+
|
|
395
|
+
```sh
|
|
396
|
+
php artisan ziprack:db:snapshot my-applet
|
|
397
|
+
php artisan ziprack:db:snapshots my-applet # list them, with ids
|
|
398
|
+
php artisan ziprack:db:restore my-applet 42 --force
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
A restore replaces all of the applet's records, its collection registry, its ledger and its role
|
|
402
|
+
assignments with the snapshot's, then makes the version that was current when it was taken current
|
|
403
|
+
again, if that version is still `ready`. The file's sha256 is checked first; a snapshot that does
|
|
404
|
+
not match is not restored.
|
|
405
|
+
|
|
406
|
+
The file is gzipped NDJSON: a header line (`format: ziprack-snapshot/1`, with the registry, the
|
|
407
|
+
ledger, role assignments and record counts), then one line per record. It is stored at
|
|
408
|
+
`ziprack/snapshots/{slug}/…ndjson.gz` on `ziprack-db.snapshots.disk`, which defaults to
|
|
409
|
+
`ziprack.disk`. **That disk must be persistent** — S3 in production, like packages.
|
|
410
|
+
|
|
411
|
+
The newest `ziprack-db.snapshots.keep` (10) are kept per applet, and never at the expense of the
|
|
412
|
+
newest one taken before a cutover.
|
|
413
|
+
|
|
206
414
|
<a name="how-it-is-stored"></a>
|
|
207
415
|
## How It Is Stored
|
|
208
416
|
|
|
209
417
|
Worth knowing, because it explains the constraints above.
|
|
210
418
|
|
|
211
419
|
Every collection's records live in one shared `ziprack_records` table, created by a migration and
|
|
212
|
-
keyed by collection and scope
|
|
420
|
+
keyed by collection and scope — the attempt, participant or room id, or `0` for `applet` scope.
|
|
421
|
+
The document lives in a JSON column; filters and ordering on an
|
|
213
422
|
`indexed` path read that path out of the JSON, typed from the declared schema so numbers compare as
|
|
214
423
|
numbers. Every query is narrowed by an index on `(collection_id, scope_id)` first, and the quota caps
|
|
215
424
|
a scope's records, so that read is always over a small, bounded set. The declared schema is still
|
|
@@ -221,10 +430,44 @@ with `is_preview` set when it is created. The flag never changes after that, so
|
|
|
221
430
|
the flag of whoever wrote each card. Applets never see it; it exists for the admin, which leaves
|
|
222
431
|
preview records out unless asked.
|
|
223
432
|
|
|
224
|
-
**Installing a version never changes the database schema.** Adding a collection or an
|
|
225
|
-
only writes rows, so
|
|
226
|
-
current
|
|
433
|
+
**Installing or promoting a version never changes the database schema.** Adding a collection or an
|
|
434
|
+
indexed path only writes rows, so both stay one transaction on every database. The collection
|
|
435
|
+
registry follows the current version: promotion updates each definition and sets aside the
|
|
436
|
+
collections the new version no longer declares. They keep their records until you run:
|
|
227
437
|
|
|
228
438
|
```sh
|
|
229
439
|
php artisan ziprack:db:prune
|
|
440
|
+
php artisan ziprack:db:prune --stranded # also the rows described below
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**Upgrading from a Rack before v1.8.** Applet-scoped records used to belong to the version that
|
|
444
|
+
wrote them, so every upgrade started from an empty dataset. A migration re-keys the current
|
|
445
|
+
version's rows to the applet; earlier versions' rows are left where they were, unreachable. Bring
|
|
446
|
+
one version's rows back into the live dataset, or delete them all:
|
|
447
|
+
|
|
448
|
+
```sh
|
|
449
|
+
php artisan ziprack:db:recover my-applet 1.2.0 [collection] [--replace] [--force]
|
|
450
|
+
php artisan ziprack:db:prune --stranded
|
|
230
451
|
```
|
|
452
|
+
|
|
453
|
+
`recover` adds to the live records (`--replace` deletes those first), reports how many recovered
|
|
454
|
+
records the current schema rejects, and takes a snapshot before it changes anything.
|
|
455
|
+
|
|
456
|
+
<a name="rack-side"></a>
|
|
457
|
+
## Rack Side
|
|
458
|
+
|
|
459
|
+
| Command | Does |
|
|
460
|
+
|---|---|
|
|
461
|
+
| `ziprack:db:prune [--stranded] [--force]` | Delete set-aside collections and their records; with `--stranded`, pre-v1.8 applet rows too. |
|
|
462
|
+
| `ziprack:db:recover {slug} {version} [collection] [--replace] [--force]` | Bring an earlier version's stranded applet-scoped records back. |
|
|
463
|
+
| `ziprack:db:snapshot {slug}` · `ziprack:db:snapshots {slug}` | Take a snapshot; list them. |
|
|
464
|
+
| `ziprack:db:restore {slug} {snapshot} [--force]` | Replace the applet's data with a snapshot. |
|
|
465
|
+
|
|
466
|
+
The admin API routes — the record browser, the cutover plan and check, snapshots — are listed in
|
|
467
|
+
[Reporting](reporting.md#the-admin-api).
|
|
468
|
+
|
|
469
|
+
A Rack that lets its operators edit records uses `ZipRack\Db\RecordAdmin`: `create()` (into an
|
|
470
|
+
`applet` collection only, since every other scope belongs to a participant, room or attempt),
|
|
471
|
+
`update()`, `setField()` across many records, all or none, and `delete()`. Every write is checked
|
|
472
|
+
against the schema and quota exactly as an applet's write is, and fires the same `RecordChanged`
|
|
473
|
+
event, with a `null` participant, so a live room sees the edit.
|