@valbuild/server 0.120.4 → 0.121.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/CHANGELOG.md +110 -0
- package/dist/declarations/src/Service.d.ts +15 -0
- package/dist/declarations/src/ValOps.d.ts +89 -3
- package/dist/declarations/src/ValOpsFS.d.ts +2 -2
- package/dist/declarations/src/ValOpsHttp.d.ts +156 -5
- package/dist/declarations/src/ValServer.d.ts +106 -1
- package/dist/valbuild-server.cjs.dev.js +1294 -27
- package/dist/valbuild-server.cjs.prod.js +1294 -27
- package/dist/valbuild-server.esm.js +1295 -28
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
# @valbuild/server
|
|
2
2
|
|
|
3
|
+
## 0.121.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#464](https://github.com/valbuild/val/pull/464) [`2bcc6fd`](https://github.com/valbuild/val/commit/2bcc6fdff8d668123e07e3c5e81ac6fa1436e47b) Thanks [@freekh](https://github.com/freekh)! - Add staging and unstaging of pending changes, so one person can publish a small fix without shipping somebody else's unfinished work.
|
|
8
|
+
|
|
9
|
+
A **patch group** is the set of patches one user has chosen to publish. It is not a patch _set_: a patch set is computed from the schema and says which patches must move together, while a patch group is curated and says which ones you want live.
|
|
10
|
+
|
|
11
|
+
A group holds its owner's own work plus whatever the closure entangled with it — not everything pending. **So Publish changes meaning on a shared branch: it ships your changes and what they depend on, instead of everything anybody has pending.** That is the feature. Unstaging goes further: hold one of your own changes back and it leaves both your preview and your publish, while still existing for everyone else.
|
|
12
|
+
|
|
13
|
+
The rule relating the two is that for every group and every patch set, the group's members within that patch set must form a prefix in patch-chain order. Staging a change therefore pulls in whatever preceded it in the same patch set; unstaging drops whatever was built on top of it. The compare view names what a toggle moves, and whose it is, rather than quietly enlarging or shrinking a publish.
|
|
14
|
+
|
|
15
|
+
Editing inside a region you are holding back is allowed, and the patches you were holding are loaded back in rather than the edit being refused. An earlier design made such a region read-only until it was staged again, because an author picks an array index while looking at their own view — so re-staging patches afterwards can shift the content under the path they just chose, and their edit lands on the wrong element cleanly, with every invariant intact and only the content wrong. That guard is not what ships. It is a rare shape in practice, since two people's edits mostly land in different routes, and refusing an edit for a reason the author cannot see is a worse everyday experience than the case it prevents. Instead the real result is shown immediately: the widened set is what the editor renders and what the compare view lists.
|
|
16
|
+
|
|
17
|
+
Also fixes a pre-existing bug in patch set grouping: patch set paths were compared with a raw string prefix test, and nothing terminates a path segment, so `?foobar/title` matched `?foo`. Deleting record key `foo` and retitling record key `foobar` were treated as one inseparable change. Previously that over-grouped two unrelated edits in the review screen; with staging it would have meant publishing a deletion nobody asked for.
|
|
18
|
+
|
|
19
|
+
The `/patches` routes gain optional patch group fields and `/patch-groups/~/patches` is new. This needs a content API that has patch groups. Filesystem mode keeps the group in the client, since it has a single author and already sends an explicit patch id list when publishing.
|
|
20
|
+
|
|
21
|
+
When a save pulls other people's changes in, you are told: a toast names how many and whose. There is no undo, because your edit was written against the view those changes produce and now depends on them — the compare view shows the widened set.
|
|
22
|
+
|
|
23
|
+
Two other things keep a session honest about a shared branch. `/stat` now says which pending changes have already been published, so another author's publish stops looking pending in your Studio the moment it lands rather than when the site redeploys. And Publish refuses, without writing anything, if somebody published while you were reviewing — the review screen you acted on described a branch that has since moved.
|
|
24
|
+
|
|
25
|
+
Two things this does **not** do yet, both of which need the group annotation to refresh on its own rather than only inside a fetch for missing patch ids:
|
|
26
|
+
|
|
27
|
+
- a stage or unstage in one tab does not reach another tab;
|
|
28
|
+
- if persisting a stage fails, the local view keeps it until the page is reloaded.
|
|
29
|
+
|
|
30
|
+
`docs/independent-publish/DESIGN.md` describes the model and lists what is still a judgement call.
|
|
31
|
+
|
|
32
|
+
- [#605](https://github.com/valbuild/val/pull/605) [`6794d29`](https://github.com/valbuild/val/commit/6794d2980bc81284ab7f2cc667f01cc21c9e3a79) Thanks [@freekh](https://github.com/freekh)! - `s.settings()`: the project's settings, as content.
|
|
33
|
+
|
|
34
|
+
A settings module is one per project, at the root of the content tree:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// settings.val.ts
|
|
38
|
+
export default c.define("/settings.val.ts", s.settings(), {});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Register it in `val.modules.ts` like any other module, and it shows up in the
|
|
42
|
+
Studio under the cog at the foot of the left rail. Everything in it is content:
|
|
43
|
+
it is edited as a draft, it appears in the publish diff, and it is the same for
|
|
44
|
+
everyone working on the project.
|
|
45
|
+
|
|
46
|
+
Every key is optional, at every level, so `{}` is a complete settings module —
|
|
47
|
+
and stays one as sections are added. What it holds today is the assistant:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
export default c.define("/settings.val.ts", s.settings(), {
|
|
51
|
+
assistant: {
|
|
52
|
+
enabled: true,
|
|
53
|
+
context: "A CMS for developers, run by a team of four in Oslo.",
|
|
54
|
+
tone: "Plain and direct. British English, sentence case in headings.",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`context` is background the assistant would otherwise guess at; `tone` is how it
|
|
60
|
+
should write when it writes content. Both are sent with every message it makes.
|
|
61
|
+
|
|
62
|
+
`enabled` decides whether editors have an assistant, and it has **three** states
|
|
63
|
+
rather than two:
|
|
64
|
+
|
|
65
|
+
- `true` — they do.
|
|
66
|
+
- `false` — they do not, and every trace of it goes: no button in the top bar,
|
|
67
|
+
no row in the quick actions, no panel, nothing sent.
|
|
68
|
+
- unset — nobody has decided. The assistant is still **shown**, and asks to be
|
|
69
|
+
turned on before it is used. Hiding an assistant nobody has decided about
|
|
70
|
+
means nobody discovers it; quietly enabling one means a project starts sending
|
|
71
|
+
its content to a model because it did not know to say no.
|
|
72
|
+
|
|
73
|
+
A project with no settings module at all has an assistant, as before: there is
|
|
74
|
+
nowhere to record a decision, and nowhere for the prompt to write the answer.
|
|
75
|
+
|
|
76
|
+
**Breaking: `ai.chat` is gone from `val.config.ts`.** Whether the assistant is
|
|
77
|
+
available is a decision about the project's content, made by the people who edit
|
|
78
|
+
it, so it moved to settings — turning the chat on used to take a developer, a
|
|
79
|
+
deploy and a code review of a boolean. Remove the whole block:
|
|
80
|
+
|
|
81
|
+
```diff
|
|
82
|
+
const { s, c, val, config } = initVal({
|
|
83
|
+
- ai: {
|
|
84
|
+
- chat: {
|
|
85
|
+
- experimental: { enable: true },
|
|
86
|
+
- suggestions: ["Summarize", "Fix typos at this page"],
|
|
87
|
+
- title: "Ask me anything",
|
|
88
|
+
- description: "Val can answer questions about the content.",
|
|
89
|
+
- },
|
|
90
|
+
- },
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`experimental.enable` becomes `assistant.enabled` in the settings module.
|
|
95
|
+
`suggestions`, `title` and `description` are removed with nothing replacing
|
|
96
|
+
them: the assistant now opens with its own copy. A project that had the chat
|
|
97
|
+
enabled and wants it to stay on for everyone should write
|
|
98
|
+
`assistant: { enabled: true }` — otherwise editors are offered it and asked.
|
|
99
|
+
|
|
100
|
+
`ai.commitMessages` stays in `val.config.ts`, and is unaffected.
|
|
101
|
+
|
|
102
|
+
Two settings modules, or one in a subdirectory, is a module error: the dev
|
|
103
|
+
server refuses to serve sources, `npx val validate` reports it against the file,
|
|
104
|
+
and the Studio says so rather than picking one.
|
|
105
|
+
|
|
106
|
+
### Patch Changes
|
|
107
|
+
|
|
108
|
+
- Updated dependencies [[`105479b`](https://github.com/valbuild/val/commit/105479b84a08846f1fe5971916f6a54275198d12), [`55ec736`](https://github.com/valbuild/val/commit/55ec73651394908b6f440e360d181b95a91c0a93), [`2bcc6fd`](https://github.com/valbuild/val/commit/2bcc6fdff8d668123e07e3c5e81ac6fa1436e47b), [`2bcbee1`](https://github.com/valbuild/val/commit/2bcbee1be682c2bbd5b7bc7d152ddd4204162fd2), [`6794d29`](https://github.com/valbuild/val/commit/6794d2980bc81284ab7f2cc667f01cc21c9e3a79), [`2db27d5`](https://github.com/valbuild/val/commit/2db27d555441bee2dd31817acc8c92b7b718ee55)]:
|
|
109
|
+
- @valbuild/ui@0.121.0
|
|
110
|
+
- @valbuild/shared@0.121.0
|
|
111
|
+
- @valbuild/core@0.121.0
|
|
112
|
+
|
|
3
113
|
## 0.120.4
|
|
4
114
|
|
|
5
115
|
### Patch Changes
|
|
@@ -14,6 +14,21 @@ export declare class Service {
|
|
|
14
14
|
* The module file paths that are registered in the project's val.modules.
|
|
15
15
|
*/
|
|
16
16
|
getModuleFilePaths(): ModuleFilePath[];
|
|
17
|
+
/**
|
|
18
|
+
* Everything that is wrong with how the project's modules are DECLARED, as
|
|
19
|
+
* opposed to what is in them.
|
|
20
|
+
*
|
|
21
|
+
* A module that failed to load is here, and so is a rule that spans the whole
|
|
22
|
+
* set — "one settings module, at the root" cannot be checked while looking at
|
|
23
|
+
* a single module, so `extractValModules` appends it with the offending path.
|
|
24
|
+
* `get` only surfaces these when the module is missing entirely, which a
|
|
25
|
+
* misplaced settings module is not: `val validate` reads them from here and
|
|
26
|
+
* reports them against the file.
|
|
27
|
+
*/
|
|
28
|
+
getModuleErrors(): {
|
|
29
|
+
message: string;
|
|
30
|
+
path?: ModuleFilePath;
|
|
31
|
+
}[];
|
|
17
32
|
private serializedSchemaOf;
|
|
18
33
|
get(moduleFilePath: ModuleFilePath, modulePath: ModulePath, options?: {
|
|
19
34
|
validate: boolean;
|
|
@@ -220,8 +220,11 @@ export declare abstract class ValOps {
|
|
|
220
220
|
* in-flight client patches the server has not seen) must pass
|
|
221
221
|
* `applyPatches: false` or the same edits would be applied twice.
|
|
222
222
|
*/
|
|
223
|
-
getJsonEntry(moduleFilePath: ModuleFilePath, entryKey: string,
|
|
223
|
+
getJsonEntry(moduleFilePath: ModuleFilePath, entryKey: string,
|
|
224
|
+
/** Passed straight through — see {@link getJsonEntries}. */
|
|
225
|
+
opts?: {
|
|
224
226
|
applyPatches?: boolean;
|
|
227
|
+
patchIds?: PatchId[];
|
|
225
228
|
}): Promise<{
|
|
226
229
|
status: "success";
|
|
227
230
|
content: JSONValue | null;
|
|
@@ -260,6 +263,15 @@ export declare abstract class ValOps {
|
|
|
260
263
|
limit: number;
|
|
261
264
|
}, opts?: {
|
|
262
265
|
applyPatches?: boolean;
|
|
266
|
+
/**
|
|
267
|
+
* Only these pending patches, or every one when `undefined`.
|
|
268
|
+
*
|
|
269
|
+
* A draft render is scoped to the caller's own groups, and a page renders
|
|
270
|
+
* `jsonValues` entries beside module content — so without this the two
|
|
271
|
+
* halves of one page disagreed about whose unpublished work they showed.
|
|
272
|
+
* `undefined` is what every other caller passes and must keep getting.
|
|
273
|
+
*/
|
|
274
|
+
patchIds?: PatchId[];
|
|
263
275
|
}): Promise<{
|
|
264
276
|
status: "success";
|
|
265
277
|
entries: {
|
|
@@ -362,10 +374,25 @@ export declare abstract class ValOps {
|
|
|
362
374
|
readProjectFile(path: string): Promise<WithGenericError<{
|
|
363
375
|
data: string;
|
|
364
376
|
}>>;
|
|
365
|
-
createPatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, parentRef: ParentRef, sessionId: string | null, authorId: AuthorId | null
|
|
377
|
+
createPatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, parentRef: ParentRef, sessionId: string | null, authorId: AuthorId | null,
|
|
378
|
+
/**
|
|
379
|
+
* Which patch group this patch joins, recorded in the SAME request.
|
|
380
|
+
*
|
|
381
|
+
* Atomic on purpose. The content API runs every refusal before its insert,
|
|
382
|
+
* so an invalid closure is a 400 with nothing written. Recording membership
|
|
383
|
+
* in a second call would let a patch exist outside its author's group if
|
|
384
|
+
* that call failed — and a patch outside your own group is one you cannot
|
|
385
|
+
* publish until a repair puts it back.
|
|
386
|
+
*
|
|
387
|
+
* Optional: `fs` mode has no groups, and a client that predates them sends
|
|
388
|
+
* nothing.
|
|
389
|
+
*/
|
|
390
|
+
patchGroup?: PatchGroupMembership): Promise<result.Result<{
|
|
366
391
|
error?: undefined;
|
|
367
392
|
patchId: PatchId;
|
|
368
393
|
createdAt: string;
|
|
394
|
+
/** See {@link SaveSourceFilePatchResult} — absent where there are no groups. */
|
|
395
|
+
patchGroupId?: string;
|
|
369
396
|
}, {
|
|
370
397
|
errorType: "other";
|
|
371
398
|
error: GenericErrorMessage;
|
|
@@ -377,7 +404,7 @@ export declare abstract class ValOps {
|
|
|
377
404
|
patchIds?: PatchId[];
|
|
378
405
|
excludePatchOps: ExcludePatchOps;
|
|
379
406
|
}): Promise<ExcludePatchOps extends true ? OrderedPatchesMetadata : OrderedPatches>;
|
|
380
|
-
protected abstract saveSourceFilePatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, parentRef: ParentRef | null, authorId: AuthorId | null, sessionId: string | null): Promise<SaveSourceFilePatchResult>;
|
|
407
|
+
protected abstract saveSourceFilePatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, parentRef: ParentRef | null, authorId: AuthorId | null, sessionId: string | null, patchGroup?: PatchGroupMembership): Promise<SaveSourceFilePatchResult>;
|
|
381
408
|
protected abstract getSourceFile(path: string): Promise<WithGenericError<{
|
|
382
409
|
data: string;
|
|
383
410
|
}>>;
|
|
@@ -414,8 +441,37 @@ export type GenericErrorMessage = {
|
|
|
414
441
|
message: string;
|
|
415
442
|
details?: unknown;
|
|
416
443
|
};
|
|
444
|
+
/**
|
|
445
|
+
* The patch group a newly created patch joins.
|
|
446
|
+
*
|
|
447
|
+
* `withPatchIds` is the CLOSURE the client computed — the patches that share
|
|
448
|
+
* a patch set with this one and must move with it. It is not derived here and
|
|
449
|
+
* must not be: the closure needs the content schema, and the service that
|
|
450
|
+
* stores groups does not have it. One implementation of that rule, on the side
|
|
451
|
+
* that can actually compute it.
|
|
452
|
+
*
|
|
453
|
+
* Membership rows are stamped with `coreVersion` on the content side, the same
|
|
454
|
+
* stamp the patch row itself carries, so which client wrote a row stays legible
|
|
455
|
+
* after the fact.
|
|
456
|
+
*/
|
|
457
|
+
export type PatchGroupMembership = {
|
|
458
|
+
/**
|
|
459
|
+
* Absent means "the author's open group, created if absent" — the content API
|
|
460
|
+
* resolves it. The client does not hold an id across publishes, because a
|
|
461
|
+
* published group is refused and the stale id would lose the write.
|
|
462
|
+
*/
|
|
463
|
+
patchGroupId?: string;
|
|
464
|
+
withPatchIds: PatchId[];
|
|
465
|
+
};
|
|
417
466
|
export type SaveSourceFilePatchResult = result.Result<{
|
|
418
467
|
patchId: PatchId;
|
|
468
|
+
/**
|
|
469
|
+
* The group the patch ended up in, where the store has groups at all.
|
|
470
|
+
*
|
|
471
|
+
* Absent in `fs` mode and against a content API that predates groups. The
|
|
472
|
+
* client uses it to learn the id of the group its own first write created.
|
|
473
|
+
*/
|
|
474
|
+
patchGroupId?: string;
|
|
419
475
|
}, ({
|
|
420
476
|
errorType: "other";
|
|
421
477
|
} & GenericErrorMessage) | {
|
|
@@ -523,6 +579,36 @@ export type PatchReadError = {
|
|
|
523
579
|
parentPatchId: ParentPatchId;
|
|
524
580
|
message: string;
|
|
525
581
|
};
|
|
582
|
+
/**
|
|
583
|
+
* The patches a json entry render should apply, out of the whole chain.
|
|
584
|
+
*
|
|
585
|
+
* Three rules, and the second is the one that was missing. A draft page renders
|
|
586
|
+
* `jsonValues` entries beside module content, and only the modules were scoped
|
|
587
|
+
* — so one screen showed the caller's own view for its modules and base plus
|
|
588
|
+
* EVERY pending patch on the branch for the entries beside them, including
|
|
589
|
+
* another author's half-finished edit rendered as though it were live.
|
|
590
|
+
*
|
|
591
|
+
* 1. this module's, since the chain is branch-wide;
|
|
592
|
+
* 2. this caller's, when they asked to be scoped. `undefined` is "everything",
|
|
593
|
+
* which is what every unscoped caller gets and must keep getting;
|
|
594
|
+
* 3. not already applied — a fact about this path rather than about scoping,
|
|
595
|
+
* and true with or without a scope.
|
|
596
|
+
*
|
|
597
|
+
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
598
|
+
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
599
|
+
* and return the whole chain. That is the right default for a caller that
|
|
600
|
+
* cannot mean "none", and the most dangerous possible reading of a group that
|
|
601
|
+
* is genuinely empty — it would render every unpublished patch on the branch
|
|
602
|
+
* instead of base. It costs no round trip either: the whole chain is what the
|
|
603
|
+
* unscoped path fetches anyway.
|
|
604
|
+
*/
|
|
605
|
+
export declare function scopedModulePatches<T extends {
|
|
606
|
+
path: ModuleFilePath;
|
|
607
|
+
patchId: PatchId;
|
|
608
|
+
appliedAt: {
|
|
609
|
+
commitSha: CommitSha;
|
|
610
|
+
} | null;
|
|
611
|
+
}>(patches: T[], moduleFilePath: ModuleFilePath, patchIds: PatchId[] | undefined): T[];
|
|
526
612
|
export type OrderedPatches = {
|
|
527
613
|
patches: {
|
|
528
614
|
path: ModuleFilePath;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PatchId, ModuleFilePath, ValModules } from "@valbuild/core";
|
|
2
|
-
import { AuthorId, BaseSha, BinaryFileType, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, SchemaSha, CommitSha, OrderedPatches, OrderedPatchesMetadata, SourcesSha } from "./ValOps.js";
|
|
2
|
+
import { AuthorId, BaseSha, BinaryFileType, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, type PatchGroupMembership, SchemaSha, CommitSha, OrderedPatches, OrderedPatchesMetadata, SourcesSha } from "./ValOps.js";
|
|
3
3
|
import { Patch, ParentRef, ValCommit } from "@valbuild/shared/internal";
|
|
4
4
|
import { Buffer } from "buffer";
|
|
5
5
|
export declare class ValOpsFS extends ValOps {
|
|
@@ -102,7 +102,7 @@ export declare class ValOpsFS extends ValOps {
|
|
|
102
102
|
excludePatchOps: ExcludePatchOps;
|
|
103
103
|
}): Promise<ExcludePatchOps extends true ? OrderedPatchesMetadata : OrderedPatches>;
|
|
104
104
|
private parseJsonFile;
|
|
105
|
-
protected saveSourceFilePatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, _parentRef: ParentRef, authorId: AuthorId | null, sessionId: string | null): Promise<SaveSourceFilePatchResult>;
|
|
105
|
+
protected saveSourceFilePatch(path: ModuleFilePath, patch: Patch, patchId: PatchId, _parentRef: ParentRef, authorId: AuthorId | null, sessionId: string | null, _patchGroup?: PatchGroupMembership): Promise<SaveSourceFilePatchResult>;
|
|
106
106
|
protected getSourceFile(path: string): Promise<WithGenericError<{
|
|
107
107
|
data: string;
|
|
108
108
|
}>>;
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { type PatchId, type ModuleFilePath, ValModules } from "@valbuild/core";
|
|
2
2
|
import type { Patch as PatchT, ParentRef as ParentRefT } from "@valbuild/core/patch";
|
|
3
|
-
import { type AuthorId, type BaseSha, BinaryFileType, type CommitSha, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, SchemaSha, OrderedPatchesMetadata, OrderedPatches, SourcesSha } from "./ValOps.js";
|
|
3
|
+
import { type AuthorId, type BaseSha, BinaryFileType, type CommitSha, GenericErrorMessage, MetadataOfType, OpsMetadata, PreparedCommit, ValOps, ValOpsOptions, WithGenericError, SaveSourceFilePatchResult, type PatchGroupMembership, SchemaSha, OrderedPatchesMetadata, OrderedPatches, SourcesSha } from "./ValOps.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { ParentRef, ValCommit, ValDeployment } from "@valbuild/shared/internal";
|
|
5
|
+
import { ParentRef, ValCommit, ValDeployment, type PatchGroupT } from "@valbuild/shared/internal";
|
|
6
6
|
declare const PatchId: z.ZodString & z.ZodType<PatchId, string, z.core.$ZodTypeInternals<PatchId, string>>;
|
|
7
7
|
declare const CommitSha: z.ZodString & z.ZodType<CommitSha, string, z.core.$ZodTypeInternals<CommitSha, string>>;
|
|
8
8
|
declare const BaseSha: z.ZodString & z.ZodType<BaseSha, string, z.core.$ZodTypeInternals<BaseSha, string>>;
|
|
9
9
|
declare const AuthorId: z.ZodString & z.ZodType<AuthorId, string, z.core.$ZodTypeInternals<AuthorId, string>>;
|
|
10
10
|
declare const ModuleFilePath: z.ZodString & z.ZodType<ModuleFilePath, string, z.core.$ZodTypeInternals<ModuleFilePath, string>>;
|
|
11
|
+
export type PatchGroupMutationResult = {
|
|
12
|
+
patchIds: PatchId[];
|
|
13
|
+
status?: undefined;
|
|
14
|
+
error?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
patchIds: PatchId[];
|
|
17
|
+
status: 403 | 409 | 500;
|
|
18
|
+
error: GenericErrorMessage;
|
|
19
|
+
};
|
|
11
20
|
export declare class ValOpsHttp extends ValOps {
|
|
12
21
|
private readonly contentUrl;
|
|
13
22
|
private readonly project;
|
|
@@ -68,6 +77,10 @@ export declare class ValOpsHttp extends ValOps {
|
|
|
68
77
|
commits: ValCommit[];
|
|
69
78
|
deployments: ValDeployment[];
|
|
70
79
|
patches: PatchId[];
|
|
80
|
+
/** Of `patches`, the ones that have shipped. See the implementation. */
|
|
81
|
+
appliedPatches: PatchId[];
|
|
82
|
+
/** The newest commit, which is the publish head. */
|
|
83
|
+
headCommitSha?: string;
|
|
71
84
|
} | {
|
|
72
85
|
type: "error";
|
|
73
86
|
error: GenericErrorMessage;
|
|
@@ -92,7 +105,120 @@ export declare class ValOpsHttp extends ValOps {
|
|
|
92
105
|
patchIds?: PatchId[];
|
|
93
106
|
excludePatchOps: ExcludePatchOps;
|
|
94
107
|
}): Promise<ExcludePatchOps extends true ? OrderedPatchesMetadata : OrderedPatches>;
|
|
95
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Add patches to a patch group.
|
|
110
|
+
*
|
|
111
|
+
* The set arrives closed by the client — `withPatchIds` is the prefix closure
|
|
112
|
+
* over the patch sets the staged patches belong to. We forward it and do not
|
|
113
|
+
* second-guess it: deriving the closure needs the content schema, which this
|
|
114
|
+
* process does have but content.val.build does not, and having two
|
|
115
|
+
* implementations of the rule would be worse than having one.
|
|
116
|
+
*
|
|
117
|
+
* Membership rows are stamped with `coreVersion` on the content side — the
|
|
118
|
+
* same stamp the patch row carries — so which client wrote a row stays
|
|
119
|
+
* legible after the fact.
|
|
120
|
+
*/
|
|
121
|
+
stagePatches(patchGroupId: string,
|
|
122
|
+
/** What the user asked to stage. */
|
|
123
|
+
patchIds: PatchId[],
|
|
124
|
+
/**
|
|
125
|
+
* What has to come with it, because the staged patches are written on top
|
|
126
|
+
* of it.
|
|
127
|
+
*
|
|
128
|
+
* The content API stores each membership row as `explicit` or `dependency`
|
|
129
|
+
* and treats what it is not told about as a dependency. Folding the two
|
|
130
|
+
* halves into `patchIds` therefore files the patch somebody clicked as one
|
|
131
|
+
* the closure dragged in — the exact opposite of what happened, and the
|
|
132
|
+
* only record anywhere of what the author chose.
|
|
133
|
+
*/
|
|
134
|
+
withPatchIds: PatchId[],
|
|
135
|
+
/**
|
|
136
|
+
* WHO is asking, so the content API can refuse a group that is not theirs.
|
|
137
|
+
*
|
|
138
|
+
* Every call from this class carries the app's API key, which says which
|
|
139
|
+
* PROJECT is calling and nothing about which editor. Without this the
|
|
140
|
+
* content API cannot tell one of a project's editors from another, so the
|
|
141
|
+
* only check on stage and unstage is the one in `ValServer` — and anything
|
|
142
|
+
* reaching the content API by another route (an API key, a PAT) has none at
|
|
143
|
+
* all.
|
|
144
|
+
*
|
|
145
|
+
* `null` where there is no session. The content API refuses rather than
|
|
146
|
+
* treating that as a match: a group written by an api key has a null author
|
|
147
|
+
* too, and `null === null` must not read as ownership.
|
|
148
|
+
*/
|
|
149
|
+
authorId: AuthorId | null): Promise<PatchGroupMutationResult>;
|
|
150
|
+
/**
|
|
151
|
+
* Remove patches from a patch group.
|
|
152
|
+
*
|
|
153
|
+
* The set arrives closed FORWARDS by the client: unstaging a patch also
|
|
154
|
+
* unstages everything built on top of it within its patch sets, and that is
|
|
155
|
+
* what `withPatchIds` carries.
|
|
156
|
+
*/
|
|
157
|
+
unstagePatches(patchGroupId: string,
|
|
158
|
+
/** What the user asked to unstage. */
|
|
159
|
+
patchIds: PatchId[],
|
|
160
|
+
/** What has to go with it: everything built on top of it. */
|
|
161
|
+
withPatchIds: PatchId[],
|
|
162
|
+
/** See {@link stagePatches} — the content API's half of the ownership check. */
|
|
163
|
+
authorId: AuthorId | null): Promise<PatchGroupMutationResult>;
|
|
164
|
+
/**
|
|
165
|
+
* Every patch group on this branch, with what each holds.
|
|
166
|
+
*
|
|
167
|
+
* Read rather than mutated, and used to answer "which pending patches is THIS
|
|
168
|
+
* person allowed to see". A draft render that skips this shows base + every
|
|
169
|
+
* pending patch on the branch, including work other people have not
|
|
170
|
+
* published — which is what independent publish exists to prevent.
|
|
171
|
+
*
|
|
172
|
+
* A failure is an empty list rather than a throw, and the caller decides what
|
|
173
|
+
* that means. For a draft render the honest fallback is "show nothing
|
|
174
|
+
* pending" rather than "show everything": being shown your own committed
|
|
175
|
+
* content when the group lookup is down is a worse experience than being
|
|
176
|
+
* shown somebody else's unpublished draft is a bug.
|
|
177
|
+
*/
|
|
178
|
+
/**
|
|
179
|
+
* The last group lookup, and when it was made.
|
|
180
|
+
*
|
|
181
|
+
* A draft render calls `getPatchGroups` once per `fetchVal`, in series with
|
|
182
|
+
* the whole-chain fetch, and a page that calls `fetchVal` several times pays
|
|
183
|
+
* the round trip several times. Groups are per branch and change rarely, so a
|
|
184
|
+
* short window removes the multiplier without letting a stage go unseen for
|
|
185
|
+
* meaningfully longer than one render.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately short. This is a read whose staleness decides whose draft
|
|
188
|
+
* content someone sees, so it is a per-request de-duplication rather than a
|
|
189
|
+
* cache: a second render a second later asks again.
|
|
190
|
+
*/
|
|
191
|
+
private patchGroupsCache;
|
|
192
|
+
getPatchGroups(options?: {
|
|
193
|
+
/**
|
|
194
|
+
* Ask the content API even if a recent answer is remembered.
|
|
195
|
+
*
|
|
196
|
+
* For the checks that DECIDE something rather than render something.
|
|
197
|
+
* `refuseUnlessOwn` reads this list to say whether a group is yours, and a
|
|
198
|
+
* group is at its youngest exactly when that matters: the first write
|
|
199
|
+
* creates it, the save response names it, and the shell flushes its queued
|
|
200
|
+
* stages immediately — well inside the cache window. Served from a list
|
|
201
|
+
* fetched before the group existed, every one of those was refused 403 and
|
|
202
|
+
* dropped, so the queue that exists to survive the post-publish window
|
|
203
|
+
* persisted nothing in the flow it was built for.
|
|
204
|
+
*
|
|
205
|
+
* Not solved by shortening the window: the cache sits on this instance,
|
|
206
|
+
* which outlives the request, so "recent" is recent for the whole server
|
|
207
|
+
* and not for one render.
|
|
208
|
+
*/
|
|
209
|
+
fresh?: boolean;
|
|
210
|
+
}): Promise<{
|
|
211
|
+
status: "ok";
|
|
212
|
+
patchGroups: PatchGroupT[];
|
|
213
|
+
} | {
|
|
214
|
+
status: "unsupported";
|
|
215
|
+
} | {
|
|
216
|
+
status: "error";
|
|
217
|
+
message: string;
|
|
218
|
+
}>;
|
|
219
|
+
private fetchPatchGroups;
|
|
220
|
+
private mutatePatchGroup;
|
|
221
|
+
protected saveSourceFilePatch(path: ModuleFilePath, patch: PatchT, patchId: PatchId, parentRef: ParentRefT, authorId: AuthorId | null, sessionId: string | null, patchGroup?: PatchGroupMembership): Promise<SaveSourceFilePatchResult>;
|
|
96
222
|
/**
|
|
97
223
|
* @deprecated For HTTP ops use direct upload instead (i.e. client should upload the files directly) since hosting platforms (Vercel) might have low limits on the size of the request body.
|
|
98
224
|
*/
|
|
@@ -108,7 +234,17 @@ export declare class ValOpsHttp extends ValOps {
|
|
|
108
234
|
getBase64EncodedBinaryFileFromPatch(filePath: string, patchId: PatchId, remote: boolean): Promise<Buffer | null>;
|
|
109
235
|
protected getBase64EncodedBinaryFileMetadataFromPatch<T extends "file" | "image">(filePath: string, type: T, patchId: PatchId, remote: boolean): Promise<OpsMetadata<T>>;
|
|
110
236
|
protected getBinaryFileMetadata<T extends "file" | "image">(filePath: string, type: T): Promise<OpsMetadata<T>>;
|
|
111
|
-
deletePatches(patchIds: PatchId[]
|
|
237
|
+
deletePatches(patchIds: PatchId[],
|
|
238
|
+
/**
|
|
239
|
+
* Patches that are NOT deleted but must lose their group membership.
|
|
240
|
+
*
|
|
241
|
+
* Deleting a patch out of the middle of a patch set leaves every group
|
|
242
|
+
* still holding the rest with a non-prefix intersection — the patches after
|
|
243
|
+
* the hole were written against a view that had it. The content API cannot
|
|
244
|
+
* work out which those are (it has no schema), so the client sends the
|
|
245
|
+
* forward closure and it drops those memberships without deleting anything.
|
|
246
|
+
*/
|
|
247
|
+
unstagePatchIds?: PatchId[]): Promise<{
|
|
112
248
|
deleted: PatchId[];
|
|
113
249
|
errors?: undefined;
|
|
114
250
|
error?: undefined;
|
|
@@ -120,7 +256,22 @@ export declare class ValOpsHttp extends ValOps {
|
|
|
120
256
|
errors?: undefined;
|
|
121
257
|
deleted?: undefined;
|
|
122
258
|
}>;
|
|
123
|
-
commit(prepared: PreparedCommit, message: string, committer: AuthorId, filesDirectory: string, newBranch?: string
|
|
259
|
+
commit(prepared: PreparedCommit, message: string, committer: AuthorId, filesDirectory: string, newBranch?: string,
|
|
260
|
+
/**
|
|
261
|
+
* The patch group this commit EMPTIES, if it empties one.
|
|
262
|
+
*
|
|
263
|
+
* The content API closes the group it is given — and closes it WITHOUT
|
|
264
|
+
* checking that the commit shipped all of it, so a caller that names a
|
|
265
|
+
* group still holding work takes those patches out of every group and
|
|
266
|
+
* leaves their author unable to publish them. The client therefore sends it
|
|
267
|
+
* only when the publish accounts for everything the group still holds.
|
|
268
|
+
*
|
|
269
|
+
* Omitting it is not neutral: the commit still empties the group (the
|
|
270
|
+
* content API drops applied ids from every group), but `published_at` is
|
|
271
|
+
* never set, so the id is reused across publishes instead of a new group
|
|
272
|
+
* per publish and the "already published" refusal can never fire.
|
|
273
|
+
*/
|
|
274
|
+
patchGroupId?: string): Promise<{
|
|
124
275
|
isNotFastForward?: boolean;
|
|
125
276
|
updatedFiles: string[];
|
|
126
277
|
commit: CommitSha;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { ValModules, ValConfig } from "@valbuild/core";
|
|
1
|
+
import { ValModules, PatchId, ModuleFilePath, ValConfig } from "@valbuild/core";
|
|
2
2
|
import { Api, ServerOf } from "@valbuild/shared/internal";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { ValOpsFS } from "./ValOpsFS.js";
|
|
5
|
+
import { CommitSha } from "./ValOps.js";
|
|
6
|
+
import { ValOpsHttp } from "./ValOpsHttp.js";
|
|
4
7
|
export type ValServerOptions = {
|
|
5
8
|
route: string;
|
|
6
9
|
valEnableRedirectUrl?: string;
|
|
@@ -33,6 +36,108 @@ export type ValServerCallbacks = {
|
|
|
33
36
|
onEnable: (success: boolean) => Promise<void>;
|
|
34
37
|
onDisable: (success: boolean) => Promise<void>;
|
|
35
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Refuse to touch a group that is not the caller's.
|
|
41
|
+
*
|
|
42
|
+
* Exported for `patchGroupOwnership.test.ts`: this is the whole of the
|
|
43
|
+
* authorization for stage and unstage, and nothing else in the process checks
|
|
44
|
+
* it, so it is worth testing as a policy rather than only through a route.
|
|
45
|
+
*
|
|
46
|
+
* `getAuth` only proves a session EXISTS; it says nothing about whose
|
|
47
|
+
* group this is. And the content API cannot decide either — every call
|
|
48
|
+
* from here carries the app's API key, not the editor's identity — so if
|
|
49
|
+
* this does not check, nothing does.
|
|
50
|
+
*
|
|
51
|
+
* `GET /patches?include_patch_groups=true` hands every editor the id and
|
|
52
|
+
* author of every open group on the branch, so without this any logged-in
|
|
53
|
+
* editor can unstage another author's patches (their next publish
|
|
54
|
+
* silently ships less) or stage into their group (it silently ships
|
|
55
|
+
* more). The 403 declared for this route in `ApiRoutes.ts` was
|
|
56
|
+
* unreachable.
|
|
57
|
+
*
|
|
58
|
+
* Fails CLOSED: if the groups cannot be read, the mutation is refused
|
|
59
|
+
* rather than allowed unverified.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The patches a scoped draft render should apply: the caller's own group, plus
|
|
63
|
+
* everything already committed.
|
|
64
|
+
*
|
|
65
|
+
* Scoping is about PENDING work. A published patch stays in the chain with
|
|
66
|
+
* `appliedAt` set until the next deployment moves the base, and it is part of
|
|
67
|
+
* everyone's view in that window — the unscoped path applies it. Dropping it
|
|
68
|
+
* meant the moment somebody published, their own draft preview reverted the
|
|
69
|
+
* field they had just shipped, and nobody else saw it either until the deploy
|
|
70
|
+
* landed; anything written on top in that window is authored against content
|
|
71
|
+
* already stale on `main`.
|
|
72
|
+
*
|
|
73
|
+
* Keyed on `appliedAt` rather than on the group's `publishedAt`, because a
|
|
74
|
+
* PARTIAL publish leaves the group open with only some of its patches applied.
|
|
75
|
+
* Those are committed too, and no flag on the group names them.
|
|
76
|
+
*
|
|
77
|
+
* `undefined` scope is unscoped and never reaches here; an EMPTY scope is a
|
|
78
|
+
* caller holding nothing, and on a branch with nothing applied it correctly
|
|
79
|
+
* filters down to no patches, which renders base.
|
|
80
|
+
*/
|
|
81
|
+
export declare function scopedPatches<T extends {
|
|
82
|
+
patchId: PatchId;
|
|
83
|
+
appliedAt: {
|
|
84
|
+
commitSha: CommitSha;
|
|
85
|
+
} | null;
|
|
86
|
+
}>(patches: T[], ownPatchIds: PatchId[] | undefined): T[];
|
|
87
|
+
/**
|
|
88
|
+
* Which of the client's `unstagePatchIds` this server is willing to forward.
|
|
89
|
+
*
|
|
90
|
+
* The forward closure of a discard is the client's to compute — it needs the
|
|
91
|
+
* patch sets, which need the schema — and it was being forwarded verbatim. But
|
|
92
|
+
* the content API removes those memberships from EVERY group with no ownership
|
|
93
|
+
* check, so any logged-in editor could strip arbitrary patches out of any other
|
|
94
|
+
* author's group by attaching them to a delete of one of their own throwaway
|
|
95
|
+
* patches. That is the outcome the 403 on `/patch-groups` exists to prevent,
|
|
96
|
+
* reached by a different door: their next publish silently ships less.
|
|
97
|
+
*
|
|
98
|
+
* Neither server can compute the true closure, but this one can BOUND it. A
|
|
99
|
+
* patch can only be invalidated by a delete if it was written after that delete
|
|
100
|
+
* — its paths were chosen against a view that had it — and if it is in the same
|
|
101
|
+
* module, since a patch set never spans two. Anything outside those bounds was
|
|
102
|
+
* not in the closure whatever the client says, so it is dropped rather than
|
|
103
|
+
* refused: the delete is still correct, and refusing the whole request over an
|
|
104
|
+
* over-broad extra would turn a discard into an error the user cannot act on.
|
|
105
|
+
*
|
|
106
|
+
* Exported for the test. Pure, and given the chain rather than fetching it, so
|
|
107
|
+
* the ordering it depends on is visible in the test rather than mocked.
|
|
108
|
+
*/
|
|
109
|
+
export declare function boundUnstageClosure(
|
|
110
|
+
/** The pending chain, in chain order, as `fetchPatches` returns it. */
|
|
111
|
+
chain: readonly {
|
|
112
|
+
patchId: PatchId;
|
|
113
|
+
path: ModuleFilePath;
|
|
114
|
+
}[], deleted: readonly PatchId[], requested: readonly PatchId[]): PatchId[];
|
|
115
|
+
/**
|
|
116
|
+
* Which pending patches this caller may see, when they asked for "only mine".
|
|
117
|
+
*
|
|
118
|
+
* Shared by `/sources/~` and `/json`, and it has to be: a draft page renders
|
|
119
|
+
* both, so two answers to "whose work is this" put one person's half-finished
|
|
120
|
+
* edit on another person's preview through whichever route was not scoped. That
|
|
121
|
+
* is exactly what happened — `/json` applied every pending patch on the branch
|
|
122
|
+
* while the module content beside it was scoped.
|
|
123
|
+
*
|
|
124
|
+
* `undefined` means "apply everything", which is what every caller that does
|
|
125
|
+
* not ask for scoping gets and must keep getting.
|
|
126
|
+
*/
|
|
127
|
+
export declare function resolveOwnPatchScope(serverOps: ValOpsFS | ValOpsHttp, opts: {
|
|
128
|
+
/** A caller that named a list already knows what it wants. */
|
|
129
|
+
explicitPatchIds: PatchId[] | undefined;
|
|
130
|
+
ownGroupsOnly: boolean;
|
|
131
|
+
/** `undefined` where there is no session to have one. */
|
|
132
|
+
authorId: string | undefined;
|
|
133
|
+
}): Promise<{
|
|
134
|
+
ownPatchIds: PatchId[] | undefined;
|
|
135
|
+
scopeAlsoIncludesApplied: boolean;
|
|
136
|
+
}>;
|
|
137
|
+
export declare function refuseUnlessOwn(ops: ValOpsHttp, patchGroupId: string, authorId: string): Promise<{
|
|
138
|
+
status: 403 | 409 | 500;
|
|
139
|
+
message: string;
|
|
140
|
+
} | null>;
|
|
36
141
|
declare const IntegratedServerJwtPayload: z.ZodObject<{
|
|
37
142
|
sub: z.ZodString;
|
|
38
143
|
exp: z.ZodNumber;
|