@valbuild/server 0.139.0 → 0.139.1

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # @valbuild/server
2
2
 
3
+ ## 0.139.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#781](https://github.com/valbuild/val/pull/781) [`5ea70ce`](https://github.com/valbuild/val/commit/5ea70cefde3c74961cfa76b4a5f4c333b850b60a) Thanks [@freekh](https://github.com/freekh)! - Every open Studio now shows the same changes, staged the same way, as a reload would.
8
+
9
+ - Staging or unstaging a change in one browser or tab now shows up in your other open Studios right away, without a reload, and publishing from any of them ships the same set.
10
+ - A change you stage before you have made any edit of your own is saved straight away, instead of being kept in the tab until your first edit and lost if you reloaded first.
11
+ - If staging a change fails, the Studio now goes back to showing what is actually saved, instead of keeping the failed change on screen until a reload.
12
+
13
+ This needs the Val Build content service released alongside this version.
14
+
15
+ - Updated dependencies [[`238057d`](https://github.com/valbuild/val/commit/238057d2f17a6bdd22d0bd4316dd8e3a8843a85d), [`8f08418`](https://github.com/valbuild/val/commit/8f0841882d13c991d31dfbb03dd4873c9ab636b4), [`238057d`](https://github.com/valbuild/val/commit/238057d2f17a6bdd22d0bd4316dd8e3a8843a85d), [`db13bed`](https://github.com/valbuild/val/commit/db13bed902f9f9147feb5be07ce47fe8b5b143f9), [`5ea70ce`](https://github.com/valbuild/val/commit/5ea70cefde3c74961cfa76b4a5f4c333b850b60a)]:
16
+ - @valbuild/ui@0.139.1
17
+ - @valbuild/core@0.139.1
18
+ - @valbuild/shared@0.139.1
19
+
3
20
  ## 0.139.0
4
21
 
5
22
  ### Patch Changes
@@ -2,6 +2,7 @@ import { MediaSource, FileMetadata, FileSource, ImageMetadata, ModuleFilePath, P
2
2
  import { result } from "@valbuild/core/fp";
3
3
  import { JSONValue, ParentRef, Patch, PatchError } from "@valbuild/core/patch";
4
4
  import type { HistoryError } from "./history/HistoryError.js";
5
+ import type { PatchGroupT } from "@valbuild/shared/internal";
5
6
  import type { AffectedFile, StoredModuleVersion, CommitPage, CommitPatch, HistoricalCommit } from "./history/types.js";
6
7
  import { ValSyntaxError, ValSyntaxErrorTree } from "./patch/ts/syntax.js";
7
8
  import { ParentPatchId } from "@valbuild/core";
@@ -897,6 +898,14 @@ export type OrderedPatches = {
897
898
  * reasons.
898
899
  */
899
900
  headVersion?: number;
901
+ /**
902
+ * Every group on the branch and which of these patches each holds, read in
903
+ * the same transaction as the list and {@link headVersion}.
904
+ *
905
+ * Absent where the content service has no groups, or predates sending them
906
+ * here. Only `ValOpsHttp` fills it.
907
+ */
908
+ patchGroups?: PatchGroupT[];
900
909
  error?: GenericErrorMessage;
901
910
  errors?: PatchReadError[];
902
911
  unauthorized?: boolean;
@@ -912,6 +921,8 @@ export type OrderedPatchesMetadata = {
912
921
  headPatchId?: OrderedPatches["headPatchId"];
913
922
  /** See {@link OrderedPatches.headVersion}. */
914
923
  headVersion?: OrderedPatches["headVersion"];
924
+ /** See {@link OrderedPatches.patchGroups}. */
925
+ patchGroups?: OrderedPatches["patchGroups"];
915
926
  error?: GenericErrorMessage;
916
927
  errors?: OrderedPatches["errors"];
917
928
  unauthorized?: boolean;
@@ -22,6 +22,8 @@ declare const ModuleFilePath: z.ZodString & z.ZodType<ModuleFilePath, string, z.
22
22
  export declare function ownBranchVersion(headVersions: Record<string, number> | undefined, branch: string | undefined): number | undefined;
23
23
  export type PatchGroupMutationResult = {
24
24
  patchIds: PatchId[];
25
+ patchGroupId: string | null;
26
+ headVersion?: number;
25
27
  status?: undefined;
26
28
  error?: undefined;
27
29
  } | {
@@ -337,6 +339,8 @@ export declare class ValOpsHttp extends ValOps {
337
339
  headVersion?: number;
338
340
  /** The newest commit, which is the publish head. */
339
341
  headCommitSha?: string;
342
+ /** Who holds what, at `headVersion`. See {@link OrderedPatches.patchGroups}. */
343
+ patchGroups?: PatchGroupT[];
340
344
  } | {
341
345
  type: "error";
342
346
  error: GenericErrorMessage;
@@ -374,7 +378,12 @@ export declare class ValOpsHttp extends ValOps {
374
378
  * same stamp the patch row carries — so which client wrote a row stays
375
379
  * legible after the fact.
376
380
  */
377
- stagePatches(patchGroupId: string,
381
+ stagePatches(
382
+ /**
383
+ * The group to stage into, or `undefined` for the caller's open group on
384
+ * this branch — `~` on the content API, which creates it if there is none.
385
+ */
386
+ patchGroupId: string | undefined,
378
387
  /** What the user asked to stage. */
379
388
  patchIds: PatchId[],
380
389
  /**
@@ -410,7 +419,9 @@ export declare class ValOpsHttp extends ValOps {
410
419
  * unstages everything built on top of it within its patch sets, and that is
411
420
  * what `withPatchIds` carries.
412
421
  */
413
- unstagePatches(patchGroupId: string,
422
+ unstagePatches(
423
+ /** See {@link stagePatches}. With no open group, there is nothing to remove. */
424
+ patchGroupId: string | undefined,
414
425
  /** What the user asked to unstage. */
415
426
  patchIds: PatchId[],
416
427
  /** What has to go with it: everything built on top of it. */
@@ -473,6 +484,12 @@ export declare class ValOpsHttp extends ValOps {
473
484
  message: string;
474
485
  }>;
475
486
  private fetchPatchGroups;
487
+ /**
488
+ * Which branch `~` means, where this build knows: the branch it reads
489
+ * `applicable/patches` on, so the group staged into is the group listed.
490
+ * Without one the content API uses the project's own, as it does for reads.
491
+ */
492
+ private ownGroupBranch;
476
493
  private mutatePatchGroup;
477
494
  protected saveSourceFilePatch(path: ModuleFilePath, patch: PatchT, patchId: PatchId, parentRef: ParentRefT, authorId: AuthorId | null, sessionId: string | null, patchGroup?: PatchGroupMembership): Promise<SaveSourceFilePatchResult>;
478
495
  /**
@@ -8341,8 +8341,24 @@ const GetApplicablePatches = z.z.object({
8341
8341
  createdAt: z.z.string(),
8342
8342
  applied: z.z.object({
8343
8343
  commitSha: z.z.string()
8344
- }).nullable()
8344
+ }).nullable(),
8345
+ /**
8346
+ * The groups this patch is a member of. Read in the SAME transaction as
8347
+ * the list and its `headVersion`, which is the point: see
8348
+ * `OrderedPatches.patchGroups`. Optional for an older content service.
8349
+ */
8350
+ patchGroupIds: z.z.array(z.z.string()).optional()
8345
8351
  })),
8352
+ /**
8353
+ * The groups on the branch, without their members — those are on the
8354
+ * patches above. Optional for a content service that predates groups.
8355
+ */
8356
+ patchGroups: z.z.array(z.z.object({
8357
+ patchGroupId: z.z.string(),
8358
+ authorId: z.z.string().nullable(),
8359
+ createdAt: z.z.string(),
8360
+ publishedAt: z.z.string().nullable()
8361
+ })).optional(),
8346
8362
  /**
8347
8363
  * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
8348
8364
  * a content service that predates it sends nothing.
@@ -8597,8 +8613,11 @@ const PatchGroupsResponse = z.z.object({
8597
8613
  patchGroups: z.z.array(internal.PatchGroup)
8598
8614
  });
8599
8615
  const PatchGroupMutationResponse = z.z.object({
8600
- patchGroupId: z.z.string(),
8601
- patchIds: z.z.array(PatchId)
8616
+ /** `null` for an unstage of `~` when the caller has no open group. */
8617
+ patchGroupId: z.z.string().nullable(),
8618
+ patchIds: z.z.array(PatchId),
8619
+ /** The chain version the change committed at. Absent from an older `home`. */
8620
+ headVersion: z.z.number().optional()
8602
8621
  });
8603
8622
  const NonceResponse = z.z.object({
8604
8623
  nonce: z.z.string(),
@@ -8612,6 +8631,9 @@ const NonceResponse = z.z.object({
8612
8631
  * in one request share an answer, and the next request asks again.
8613
8632
  */
8614
8633
  const PATCH_GROUPS_CACHE_MS = 1000;
8634
+
8635
+ /** The content API's id for "the caller's open group on the branch". */
8636
+ const OWN_OPEN_GROUP = "~";
8615
8637
  class ValOpsHttp extends ValOps {
8616
8638
  authHeaders;
8617
8639
  root;
@@ -9241,6 +9263,15 @@ class ValOpsHttp extends ValOps {
9241
9263
  ...(allPatchData.headVersion !== undefined ? {
9242
9264
  headVersion: allPatchData.headVersion
9243
9265
  } : {}),
9266
+ /*
9267
+ * Who holds what, read with the list and its version. On every stat, so
9268
+ * a Studio's view of the groups is never older than its view of the
9269
+ * chain — which is what lets a stage made in another browser show here
9270
+ * without a reload. See `OrderedPatches.patchGroups`.
9271
+ */
9272
+ ...(allPatchData.patchGroups !== undefined ? {
9273
+ patchGroups: allPatchData.patchGroups
9274
+ } : {}),
9244
9275
  /*
9245
9276
  * The PUBLISH head, which is not `commitSha`.
9246
9277
  *
@@ -9547,11 +9578,43 @@ class ValOpsHttp extends ValOps {
9547
9578
  });
9548
9579
  }
9549
9580
  }
9581
+ /*
9582
+ * Membership, folded from the per-patch annotation onto the groups.
9583
+ *
9584
+ * From THIS response and not from `GET /patch-groups`, because only
9585
+ * this one is consistent with the list and the version beside it:
9586
+ * the content service reads all three in one transaction. A group
9587
+ * list fetched separately can be older or newer than the chain it is
9588
+ * shown with — and the Studio decides what an editor sees, and what
9589
+ * a publish ships, from the pair.
9590
+ *
9591
+ * A group holding none of the listed patches is still listed, with
9592
+ * no members: "groups exist and you hold nothing here" is a real
9593
+ * answer, and different from "this content service has no groups".
9594
+ */
9595
+ let patchGroups;
9596
+ if (data.patchGroups !== undefined) {
9597
+ const members = new Map();
9598
+ for (const patch of data.patches) {
9599
+ for (const groupId of patch.patchGroupIds ?? []) {
9600
+ const list = members.get(groupId) ?? [];
9601
+ list.push(patch.patchId);
9602
+ members.set(groupId, list);
9603
+ }
9604
+ }
9605
+ patchGroups = data.patchGroups.map(group => ({
9606
+ ...group,
9607
+ patchIds: members.get(group.patchGroupId) ?? []
9608
+ }));
9609
+ }
9550
9610
  return {
9551
9611
  commits,
9552
9612
  deployments,
9553
9613
  patches,
9554
9614
  errors,
9615
+ ...(patchGroups !== undefined ? {
9616
+ patchGroups
9617
+ } : {}),
9555
9618
  ...(data.headPatchId !== undefined ? {
9556
9619
  headPatchId: data.headPatchId
9557
9620
  } : {}),
@@ -9609,7 +9672,12 @@ class ValOpsHttp extends ValOps {
9609
9672
  * same stamp the patch row carries — so which client wrote a row stays
9610
9673
  * legible after the fact.
9611
9674
  */
9612
- async stagePatches(patchGroupId, /** What the user asked to stage. */
9675
+ async stagePatches(
9676
+ /**
9677
+ * The group to stage into, or `undefined` for the caller's open group on
9678
+ * this branch — `~` on the content API, which creates it if there is none.
9679
+ */
9680
+ patchGroupId, /** What the user asked to stage. */
9613
9681
  patchIds,
9614
9682
  /**
9615
9683
  * What has to come with it, because the staged patches are written on top
@@ -9641,10 +9709,11 @@ class ValOpsHttp extends ValOps {
9641
9709
  // Encoded: patchGroupId arrives in a request body, so an unencoded value
9642
9710
  // like "../../commit" would reach a different endpoint carrying this
9643
9711
  // project's auth headers.
9644
- `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9712
+ `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9645
9713
  patchIds,
9646
9714
  withPatchIds,
9647
- coreVersion: core.Internal.VERSION.core
9715
+ coreVersion: core.Internal.VERSION.core,
9716
+ ...this.ownGroupBranch()
9648
9717
  }, authorId);
9649
9718
  }
9650
9719
 
@@ -9655,13 +9724,15 @@ class ValOpsHttp extends ValOps {
9655
9724
  * unstages everything built on top of it within its patch sets, and that is
9656
9725
  * what `withPatchIds` carries.
9657
9726
  */
9658
- async unstagePatches(patchGroupId, /** What the user asked to unstage. */
9727
+ async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
9728
+ patchGroupId, /** What the user asked to unstage. */
9659
9729
  patchIds, /** What has to go with it: everything built on top of it. */
9660
9730
  withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
9661
9731
  authorId) {
9662
- return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9732
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9663
9733
  patchIds,
9664
- withPatchIds
9734
+ withPatchIds,
9735
+ ...this.ownGroupBranch()
9665
9736
  }, authorId);
9666
9737
  }
9667
9738
 
@@ -9776,6 +9847,17 @@ class ValOpsHttp extends ValOps {
9776
9847
  };
9777
9848
  }
9778
9849
  }
9850
+
9851
+ /**
9852
+ * Which branch `~` means, where this build knows: the branch it reads
9853
+ * `applicable/patches` on, so the group staged into is the group listed.
9854
+ * Without one the content API uses the project's own, as it does for reads.
9855
+ */
9856
+ ownGroupBranch() {
9857
+ return this.git ? {
9858
+ branch: this.git.branch
9859
+ } : {};
9860
+ }
9779
9861
  async mutatePatchGroup(method, path, body,
9780
9862
  /**
9781
9863
  * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
@@ -9812,7 +9894,11 @@ class ValOpsHttp extends ValOps {
9812
9894
  const parsed = PatchGroupMutationResponse.safeParse(await res.json());
9813
9895
  if (parsed.success) {
9814
9896
  return {
9815
- patchIds: parsed.data.patchIds
9897
+ patchIds: parsed.data.patchIds,
9898
+ patchGroupId: parsed.data.patchGroupId,
9899
+ ...(parsed.data.headVersion !== undefined ? {
9900
+ headVersion: parsed.data.headVersion
9901
+ } : {})
9816
9902
  };
9817
9903
  }
9818
9904
  return {
@@ -13444,7 +13530,7 @@ const ValServer = (valModules, options, callbacks) => {
13444
13530
  return {
13445
13531
  status: 200,
13446
13532
  json: {
13447
- patchGroupId,
13533
+ patchGroupId: patchGroupId ?? null,
13448
13534
  patchIds: [...patchIds, ...withPatchIds]
13449
13535
  }
13450
13536
  };
@@ -13457,14 +13543,19 @@ const ValServer = (valModules, options, callbacks) => {
13457
13543
  }
13458
13544
  };
13459
13545
  }
13460
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13461
- if (refusal !== null) {
13462
- return {
13463
- status: refusal.status,
13464
- json: {
13465
- message: refusal.message
13466
- }
13467
- };
13546
+ // A named group is checked here as well as by the content API. No name
13547
+ // is the caller's own open group, which the content API resolves from
13548
+ // the profile it is sent — there is nothing here to check it against.
13549
+ if (patchGroupId !== undefined) {
13550
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13551
+ if (refusal !== null) {
13552
+ return {
13553
+ status: refusal.status,
13554
+ json: {
13555
+ message: refusal.message
13556
+ }
13557
+ };
13558
+ }
13468
13559
  }
13469
13560
  const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
13470
13561
  // Forwarded so the content API can refuse independently. This server
@@ -13483,8 +13574,11 @@ const ValServer = (valModules, options, callbacks) => {
13483
13574
  return {
13484
13575
  status: 200,
13485
13576
  json: {
13486
- patchGroupId,
13487
- patchIds: res.patchIds
13577
+ patchGroupId: res.patchGroupId,
13578
+ patchIds: res.patchIds,
13579
+ ...(res.headVersion !== undefined ? {
13580
+ headVersion: res.headVersion
13581
+ } : {})
13488
13582
  }
13489
13583
  };
13490
13584
  },
@@ -13507,7 +13601,7 @@ const ValServer = (valModules, options, callbacks) => {
13507
13601
  return {
13508
13602
  status: 200,
13509
13603
  json: {
13510
- patchGroupId,
13604
+ patchGroupId: patchGroupId ?? null,
13511
13605
  patchIds: [...patchIds, ...withPatchIds]
13512
13606
  }
13513
13607
  };
@@ -13520,14 +13614,19 @@ const ValServer = (valModules, options, callbacks) => {
13520
13614
  }
13521
13615
  };
13522
13616
  }
13523
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13524
- if (refusal !== null) {
13525
- return {
13526
- status: refusal.status,
13527
- json: {
13528
- message: refusal.message
13529
- }
13530
- };
13617
+ // A named group is checked here as well as by the content API. No name
13618
+ // is the caller's own open group, which the content API resolves from
13619
+ // the profile it is sent — there is nothing here to check it against.
13620
+ if (patchGroupId !== undefined) {
13621
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13622
+ if (refusal !== null) {
13623
+ return {
13624
+ status: refusal.status,
13625
+ json: {
13626
+ message: refusal.message
13627
+ }
13628
+ };
13629
+ }
13531
13630
  }
13532
13631
  const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
13533
13632
  if (res.error) {
@@ -13541,8 +13640,11 @@ const ValServer = (valModules, options, callbacks) => {
13541
13640
  return {
13542
13641
  status: 200,
13543
13642
  json: {
13544
- patchGroupId,
13545
- patchIds: res.patchIds
13643
+ patchGroupId: res.patchGroupId,
13644
+ patchIds: res.patchIds,
13645
+ ...(res.headVersion !== undefined ? {
13646
+ headVersion: res.headVersion
13647
+ } : {})
13546
13648
  }
13547
13649
  };
13548
13650
  }
@@ -8341,8 +8341,24 @@ const GetApplicablePatches = z.z.object({
8341
8341
  createdAt: z.z.string(),
8342
8342
  applied: z.z.object({
8343
8343
  commitSha: z.z.string()
8344
- }).nullable()
8344
+ }).nullable(),
8345
+ /**
8346
+ * The groups this patch is a member of. Read in the SAME transaction as
8347
+ * the list and its `headVersion`, which is the point: see
8348
+ * `OrderedPatches.patchGroups`. Optional for an older content service.
8349
+ */
8350
+ patchGroupIds: z.z.array(z.z.string()).optional()
8345
8351
  })),
8352
+ /**
8353
+ * The groups on the branch, without their members — those are on the
8354
+ * patches above. Optional for a content service that predates groups.
8355
+ */
8356
+ patchGroups: z.z.array(z.z.object({
8357
+ patchGroupId: z.z.string(),
8358
+ authorId: z.z.string().nullable(),
8359
+ createdAt: z.z.string(),
8360
+ publishedAt: z.z.string().nullable()
8361
+ })).optional(),
8346
8362
  /**
8347
8363
  * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
8348
8364
  * a content service that predates it sends nothing.
@@ -8597,8 +8613,11 @@ const PatchGroupsResponse = z.z.object({
8597
8613
  patchGroups: z.z.array(internal.PatchGroup)
8598
8614
  });
8599
8615
  const PatchGroupMutationResponse = z.z.object({
8600
- patchGroupId: z.z.string(),
8601
- patchIds: z.z.array(PatchId)
8616
+ /** `null` for an unstage of `~` when the caller has no open group. */
8617
+ patchGroupId: z.z.string().nullable(),
8618
+ patchIds: z.z.array(PatchId),
8619
+ /** The chain version the change committed at. Absent from an older `home`. */
8620
+ headVersion: z.z.number().optional()
8602
8621
  });
8603
8622
  const NonceResponse = z.z.object({
8604
8623
  nonce: z.z.string(),
@@ -8612,6 +8631,9 @@ const NonceResponse = z.z.object({
8612
8631
  * in one request share an answer, and the next request asks again.
8613
8632
  */
8614
8633
  const PATCH_GROUPS_CACHE_MS = 1000;
8634
+
8635
+ /** The content API's id for "the caller's open group on the branch". */
8636
+ const OWN_OPEN_GROUP = "~";
8615
8637
  class ValOpsHttp extends ValOps {
8616
8638
  authHeaders;
8617
8639
  root;
@@ -9241,6 +9263,15 @@ class ValOpsHttp extends ValOps {
9241
9263
  ...(allPatchData.headVersion !== undefined ? {
9242
9264
  headVersion: allPatchData.headVersion
9243
9265
  } : {}),
9266
+ /*
9267
+ * Who holds what, read with the list and its version. On every stat, so
9268
+ * a Studio's view of the groups is never older than its view of the
9269
+ * chain — which is what lets a stage made in another browser show here
9270
+ * without a reload. See `OrderedPatches.patchGroups`.
9271
+ */
9272
+ ...(allPatchData.patchGroups !== undefined ? {
9273
+ patchGroups: allPatchData.patchGroups
9274
+ } : {}),
9244
9275
  /*
9245
9276
  * The PUBLISH head, which is not `commitSha`.
9246
9277
  *
@@ -9547,11 +9578,43 @@ class ValOpsHttp extends ValOps {
9547
9578
  });
9548
9579
  }
9549
9580
  }
9581
+ /*
9582
+ * Membership, folded from the per-patch annotation onto the groups.
9583
+ *
9584
+ * From THIS response and not from `GET /patch-groups`, because only
9585
+ * this one is consistent with the list and the version beside it:
9586
+ * the content service reads all three in one transaction. A group
9587
+ * list fetched separately can be older or newer than the chain it is
9588
+ * shown with — and the Studio decides what an editor sees, and what
9589
+ * a publish ships, from the pair.
9590
+ *
9591
+ * A group holding none of the listed patches is still listed, with
9592
+ * no members: "groups exist and you hold nothing here" is a real
9593
+ * answer, and different from "this content service has no groups".
9594
+ */
9595
+ let patchGroups;
9596
+ if (data.patchGroups !== undefined) {
9597
+ const members = new Map();
9598
+ for (const patch of data.patches) {
9599
+ for (const groupId of patch.patchGroupIds ?? []) {
9600
+ const list = members.get(groupId) ?? [];
9601
+ list.push(patch.patchId);
9602
+ members.set(groupId, list);
9603
+ }
9604
+ }
9605
+ patchGroups = data.patchGroups.map(group => ({
9606
+ ...group,
9607
+ patchIds: members.get(group.patchGroupId) ?? []
9608
+ }));
9609
+ }
9550
9610
  return {
9551
9611
  commits,
9552
9612
  deployments,
9553
9613
  patches,
9554
9614
  errors,
9615
+ ...(patchGroups !== undefined ? {
9616
+ patchGroups
9617
+ } : {}),
9555
9618
  ...(data.headPatchId !== undefined ? {
9556
9619
  headPatchId: data.headPatchId
9557
9620
  } : {}),
@@ -9609,7 +9672,12 @@ class ValOpsHttp extends ValOps {
9609
9672
  * same stamp the patch row carries — so which client wrote a row stays
9610
9673
  * legible after the fact.
9611
9674
  */
9612
- async stagePatches(patchGroupId, /** What the user asked to stage. */
9675
+ async stagePatches(
9676
+ /**
9677
+ * The group to stage into, or `undefined` for the caller's open group on
9678
+ * this branch — `~` on the content API, which creates it if there is none.
9679
+ */
9680
+ patchGroupId, /** What the user asked to stage. */
9613
9681
  patchIds,
9614
9682
  /**
9615
9683
  * What has to come with it, because the staged patches are written on top
@@ -9641,10 +9709,11 @@ class ValOpsHttp extends ValOps {
9641
9709
  // Encoded: patchGroupId arrives in a request body, so an unencoded value
9642
9710
  // like "../../commit" would reach a different endpoint carrying this
9643
9711
  // project's auth headers.
9644
- `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9712
+ `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9645
9713
  patchIds,
9646
9714
  withPatchIds,
9647
- coreVersion: core.Internal.VERSION.core
9715
+ coreVersion: core.Internal.VERSION.core,
9716
+ ...this.ownGroupBranch()
9648
9717
  }, authorId);
9649
9718
  }
9650
9719
 
@@ -9655,13 +9724,15 @@ class ValOpsHttp extends ValOps {
9655
9724
  * unstages everything built on top of it within its patch sets, and that is
9656
9725
  * what `withPatchIds` carries.
9657
9726
  */
9658
- async unstagePatches(patchGroupId, /** What the user asked to unstage. */
9727
+ async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
9728
+ patchGroupId, /** What the user asked to unstage. */
9659
9729
  patchIds, /** What has to go with it: everything built on top of it. */
9660
9730
  withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
9661
9731
  authorId) {
9662
- return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9732
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9663
9733
  patchIds,
9664
- withPatchIds
9734
+ withPatchIds,
9735
+ ...this.ownGroupBranch()
9665
9736
  }, authorId);
9666
9737
  }
9667
9738
 
@@ -9776,6 +9847,17 @@ class ValOpsHttp extends ValOps {
9776
9847
  };
9777
9848
  }
9778
9849
  }
9850
+
9851
+ /**
9852
+ * Which branch `~` means, where this build knows: the branch it reads
9853
+ * `applicable/patches` on, so the group staged into is the group listed.
9854
+ * Without one the content API uses the project's own, as it does for reads.
9855
+ */
9856
+ ownGroupBranch() {
9857
+ return this.git ? {
9858
+ branch: this.git.branch
9859
+ } : {};
9860
+ }
9779
9861
  async mutatePatchGroup(method, path, body,
9780
9862
  /**
9781
9863
  * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
@@ -9812,7 +9894,11 @@ class ValOpsHttp extends ValOps {
9812
9894
  const parsed = PatchGroupMutationResponse.safeParse(await res.json());
9813
9895
  if (parsed.success) {
9814
9896
  return {
9815
- patchIds: parsed.data.patchIds
9897
+ patchIds: parsed.data.patchIds,
9898
+ patchGroupId: parsed.data.patchGroupId,
9899
+ ...(parsed.data.headVersion !== undefined ? {
9900
+ headVersion: parsed.data.headVersion
9901
+ } : {})
9816
9902
  };
9817
9903
  }
9818
9904
  return {
@@ -13444,7 +13530,7 @@ const ValServer = (valModules, options, callbacks) => {
13444
13530
  return {
13445
13531
  status: 200,
13446
13532
  json: {
13447
- patchGroupId,
13533
+ patchGroupId: patchGroupId ?? null,
13448
13534
  patchIds: [...patchIds, ...withPatchIds]
13449
13535
  }
13450
13536
  };
@@ -13457,14 +13543,19 @@ const ValServer = (valModules, options, callbacks) => {
13457
13543
  }
13458
13544
  };
13459
13545
  }
13460
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13461
- if (refusal !== null) {
13462
- return {
13463
- status: refusal.status,
13464
- json: {
13465
- message: refusal.message
13466
- }
13467
- };
13546
+ // A named group is checked here as well as by the content API. No name
13547
+ // is the caller's own open group, which the content API resolves from
13548
+ // the profile it is sent — there is nothing here to check it against.
13549
+ if (patchGroupId !== undefined) {
13550
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13551
+ if (refusal !== null) {
13552
+ return {
13553
+ status: refusal.status,
13554
+ json: {
13555
+ message: refusal.message
13556
+ }
13557
+ };
13558
+ }
13468
13559
  }
13469
13560
  const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
13470
13561
  // Forwarded so the content API can refuse independently. This server
@@ -13483,8 +13574,11 @@ const ValServer = (valModules, options, callbacks) => {
13483
13574
  return {
13484
13575
  status: 200,
13485
13576
  json: {
13486
- patchGroupId,
13487
- patchIds: res.patchIds
13577
+ patchGroupId: res.patchGroupId,
13578
+ patchIds: res.patchIds,
13579
+ ...(res.headVersion !== undefined ? {
13580
+ headVersion: res.headVersion
13581
+ } : {})
13488
13582
  }
13489
13583
  };
13490
13584
  },
@@ -13507,7 +13601,7 @@ const ValServer = (valModules, options, callbacks) => {
13507
13601
  return {
13508
13602
  status: 200,
13509
13603
  json: {
13510
- patchGroupId,
13604
+ patchGroupId: patchGroupId ?? null,
13511
13605
  patchIds: [...patchIds, ...withPatchIds]
13512
13606
  }
13513
13607
  };
@@ -13520,14 +13614,19 @@ const ValServer = (valModules, options, callbacks) => {
13520
13614
  }
13521
13615
  };
13522
13616
  }
13523
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13524
- if (refusal !== null) {
13525
- return {
13526
- status: refusal.status,
13527
- json: {
13528
- message: refusal.message
13529
- }
13530
- };
13617
+ // A named group is checked here as well as by the content API. No name
13618
+ // is the caller's own open group, which the content API resolves from
13619
+ // the profile it is sent — there is nothing here to check it against.
13620
+ if (patchGroupId !== undefined) {
13621
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13622
+ if (refusal !== null) {
13623
+ return {
13624
+ status: refusal.status,
13625
+ json: {
13626
+ message: refusal.message
13627
+ }
13628
+ };
13629
+ }
13531
13630
  }
13532
13631
  const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
13533
13632
  if (res.error) {
@@ -13541,8 +13640,11 @@ const ValServer = (valModules, options, callbacks) => {
13541
13640
  return {
13542
13641
  status: 200,
13543
13642
  json: {
13544
- patchGroupId,
13545
- patchIds: res.patchIds
13643
+ patchGroupId: res.patchGroupId,
13644
+ patchIds: res.patchIds,
13645
+ ...(res.headVersion !== undefined ? {
13646
+ headVersion: res.headVersion
13647
+ } : {})
13546
13648
  }
13547
13649
  };
13548
13650
  }
@@ -8304,8 +8304,24 @@ const GetApplicablePatches = z.object({
8304
8304
  createdAt: z.string(),
8305
8305
  applied: z.object({
8306
8306
  commitSha: z.string()
8307
- }).nullable()
8307
+ }).nullable(),
8308
+ /**
8309
+ * The groups this patch is a member of. Read in the SAME transaction as
8310
+ * the list and its `headVersion`, which is the point: see
8311
+ * `OrderedPatches.patchGroups`. Optional for an older content service.
8312
+ */
8313
+ patchGroupIds: z.array(z.string()).optional()
8308
8314
  })),
8315
+ /**
8316
+ * The groups on the branch, without their members — those are on the
8317
+ * patches above. Optional for a content service that predates groups.
8318
+ */
8319
+ patchGroups: z.array(z.object({
8320
+ patchGroupId: z.string(),
8321
+ authorId: z.string().nullable(),
8322
+ createdAt: z.string(),
8323
+ publishedAt: z.string().nullable()
8324
+ })).optional(),
8309
8325
  /**
8310
8326
  * The head of the chain. See `OrderedPatches.headPatchId`. Optional because
8311
8327
  * a content service that predates it sends nothing.
@@ -8560,8 +8576,11 @@ const PatchGroupsResponse = z.object({
8560
8576
  patchGroups: z.array(PatchGroup)
8561
8577
  });
8562
8578
  const PatchGroupMutationResponse = z.object({
8563
- patchGroupId: z.string(),
8564
- patchIds: z.array(PatchId)
8579
+ /** `null` for an unstage of `~` when the caller has no open group. */
8580
+ patchGroupId: z.string().nullable(),
8581
+ patchIds: z.array(PatchId),
8582
+ /** The chain version the change committed at. Absent from an older `home`. */
8583
+ headVersion: z.number().optional()
8565
8584
  });
8566
8585
  const NonceResponse = z.object({
8567
8586
  nonce: z.string(),
@@ -8575,6 +8594,9 @@ const NonceResponse = z.object({
8575
8594
  * in one request share an answer, and the next request asks again.
8576
8595
  */
8577
8596
  const PATCH_GROUPS_CACHE_MS = 1000;
8597
+
8598
+ /** The content API's id for "the caller's open group on the branch". */
8599
+ const OWN_OPEN_GROUP = "~";
8578
8600
  class ValOpsHttp extends ValOps {
8579
8601
  authHeaders;
8580
8602
  root;
@@ -9204,6 +9226,15 @@ class ValOpsHttp extends ValOps {
9204
9226
  ...(allPatchData.headVersion !== undefined ? {
9205
9227
  headVersion: allPatchData.headVersion
9206
9228
  } : {}),
9229
+ /*
9230
+ * Who holds what, read with the list and its version. On every stat, so
9231
+ * a Studio's view of the groups is never older than its view of the
9232
+ * chain — which is what lets a stage made in another browser show here
9233
+ * without a reload. See `OrderedPatches.patchGroups`.
9234
+ */
9235
+ ...(allPatchData.patchGroups !== undefined ? {
9236
+ patchGroups: allPatchData.patchGroups
9237
+ } : {}),
9207
9238
  /*
9208
9239
  * The PUBLISH head, which is not `commitSha`.
9209
9240
  *
@@ -9510,11 +9541,43 @@ class ValOpsHttp extends ValOps {
9510
9541
  });
9511
9542
  }
9512
9543
  }
9544
+ /*
9545
+ * Membership, folded from the per-patch annotation onto the groups.
9546
+ *
9547
+ * From THIS response and not from `GET /patch-groups`, because only
9548
+ * this one is consistent with the list and the version beside it:
9549
+ * the content service reads all three in one transaction. A group
9550
+ * list fetched separately can be older or newer than the chain it is
9551
+ * shown with — and the Studio decides what an editor sees, and what
9552
+ * a publish ships, from the pair.
9553
+ *
9554
+ * A group holding none of the listed patches is still listed, with
9555
+ * no members: "groups exist and you hold nothing here" is a real
9556
+ * answer, and different from "this content service has no groups".
9557
+ */
9558
+ let patchGroups;
9559
+ if (data.patchGroups !== undefined) {
9560
+ const members = new Map();
9561
+ for (const patch of data.patches) {
9562
+ for (const groupId of patch.patchGroupIds ?? []) {
9563
+ const list = members.get(groupId) ?? [];
9564
+ list.push(patch.patchId);
9565
+ members.set(groupId, list);
9566
+ }
9567
+ }
9568
+ patchGroups = data.patchGroups.map(group => ({
9569
+ ...group,
9570
+ patchIds: members.get(group.patchGroupId) ?? []
9571
+ }));
9572
+ }
9513
9573
  return {
9514
9574
  commits,
9515
9575
  deployments,
9516
9576
  patches,
9517
9577
  errors,
9578
+ ...(patchGroups !== undefined ? {
9579
+ patchGroups
9580
+ } : {}),
9518
9581
  ...(data.headPatchId !== undefined ? {
9519
9582
  headPatchId: data.headPatchId
9520
9583
  } : {}),
@@ -9572,7 +9635,12 @@ class ValOpsHttp extends ValOps {
9572
9635
  * same stamp the patch row carries — so which client wrote a row stays
9573
9636
  * legible after the fact.
9574
9637
  */
9575
- async stagePatches(patchGroupId, /** What the user asked to stage. */
9638
+ async stagePatches(
9639
+ /**
9640
+ * The group to stage into, or `undefined` for the caller's open group on
9641
+ * this branch — `~` on the content API, which creates it if there is none.
9642
+ */
9643
+ patchGroupId, /** What the user asked to stage. */
9576
9644
  patchIds,
9577
9645
  /**
9578
9646
  * What has to come with it, because the staged patches are written on top
@@ -9604,10 +9672,11 @@ class ValOpsHttp extends ValOps {
9604
9672
  // Encoded: patchGroupId arrives in a request body, so an unencoded value
9605
9673
  // like "../../commit" would reach a different endpoint carrying this
9606
9674
  // project's auth headers.
9607
- `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9675
+ `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9608
9676
  patchIds,
9609
9677
  withPatchIds,
9610
- coreVersion: Internal.VERSION.core
9678
+ coreVersion: Internal.VERSION.core,
9679
+ ...this.ownGroupBranch()
9611
9680
  }, authorId);
9612
9681
  }
9613
9682
 
@@ -9618,13 +9687,15 @@ class ValOpsHttp extends ValOps {
9618
9687
  * unstages everything built on top of it within its patch sets, and that is
9619
9688
  * what `withPatchIds` carries.
9620
9689
  */
9621
- async unstagePatches(patchGroupId, /** What the user asked to unstage. */
9690
+ async unstagePatches(/** See {@link stagePatches}. With no open group, there is nothing to remove. */
9691
+ patchGroupId, /** What the user asked to unstage. */
9622
9692
  patchIds, /** What has to go with it: everything built on top of it. */
9623
9693
  withPatchIds, /** See {@link stagePatches} — the content API's half of the ownership check. */
9624
9694
  authorId) {
9625
- return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId)}/patches`, {
9695
+ return this.mutatePatchGroup("DELETE", `patch-groups/${encodeURIComponent(patchGroupId ?? OWN_OPEN_GROUP)}/patches`, {
9626
9696
  patchIds,
9627
- withPatchIds
9697
+ withPatchIds,
9698
+ ...this.ownGroupBranch()
9628
9699
  }, authorId);
9629
9700
  }
9630
9701
 
@@ -9739,6 +9810,17 @@ class ValOpsHttp extends ValOps {
9739
9810
  };
9740
9811
  }
9741
9812
  }
9813
+
9814
+ /**
9815
+ * Which branch `~` means, where this build knows: the branch it reads
9816
+ * `applicable/patches` on, so the group staged into is the group listed.
9817
+ * Without one the content API uses the project's own, as it does for reads.
9818
+ */
9819
+ ownGroupBranch() {
9820
+ return this.git ? {
9821
+ branch: this.git.branch
9822
+ } : {};
9823
+ }
9742
9824
  async mutatePatchGroup(method, path, body,
9743
9825
  /**
9744
9826
  * WHO is asking. Sent as `x-val-profile-id`, which is what the content API
@@ -9775,7 +9857,11 @@ class ValOpsHttp extends ValOps {
9775
9857
  const parsed = PatchGroupMutationResponse.safeParse(await res.json());
9776
9858
  if (parsed.success) {
9777
9859
  return {
9778
- patchIds: parsed.data.patchIds
9860
+ patchIds: parsed.data.patchIds,
9861
+ patchGroupId: parsed.data.patchGroupId,
9862
+ ...(parsed.data.headVersion !== undefined ? {
9863
+ headVersion: parsed.data.headVersion
9864
+ } : {})
9779
9865
  };
9780
9866
  }
9781
9867
  return {
@@ -13407,7 +13493,7 @@ const ValServer = (valModules, options, callbacks) => {
13407
13493
  return {
13408
13494
  status: 200,
13409
13495
  json: {
13410
- patchGroupId,
13496
+ patchGroupId: patchGroupId ?? null,
13411
13497
  patchIds: [...patchIds, ...withPatchIds]
13412
13498
  }
13413
13499
  };
@@ -13420,14 +13506,19 @@ const ValServer = (valModules, options, callbacks) => {
13420
13506
  }
13421
13507
  };
13422
13508
  }
13423
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13424
- if (refusal !== null) {
13425
- return {
13426
- status: refusal.status,
13427
- json: {
13428
- message: refusal.message
13429
- }
13430
- };
13509
+ // A named group is checked here as well as by the content API. No name
13510
+ // is the caller's own open group, which the content API resolves from
13511
+ // the profile it is sent — there is nothing here to check it against.
13512
+ if (patchGroupId !== undefined) {
13513
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13514
+ if (refusal !== null) {
13515
+ return {
13516
+ status: refusal.status,
13517
+ json: {
13518
+ message: refusal.message
13519
+ }
13520
+ };
13521
+ }
13431
13522
  }
13432
13523
  const res = await serverOps.stagePatches(patchGroupId, patchIds, withPatchIds,
13433
13524
  // Forwarded so the content API can refuse independently. This server
@@ -13446,8 +13537,11 @@ const ValServer = (valModules, options, callbacks) => {
13446
13537
  return {
13447
13538
  status: 200,
13448
13539
  json: {
13449
- patchGroupId,
13450
- patchIds: res.patchIds
13540
+ patchGroupId: res.patchGroupId,
13541
+ patchIds: res.patchIds,
13542
+ ...(res.headVersion !== undefined ? {
13543
+ headVersion: res.headVersion
13544
+ } : {})
13451
13545
  }
13452
13546
  };
13453
13547
  },
@@ -13470,7 +13564,7 @@ const ValServer = (valModules, options, callbacks) => {
13470
13564
  return {
13471
13565
  status: 200,
13472
13566
  json: {
13473
- patchGroupId,
13567
+ patchGroupId: patchGroupId ?? null,
13474
13568
  patchIds: [...patchIds, ...withPatchIds]
13475
13569
  }
13476
13570
  };
@@ -13483,14 +13577,19 @@ const ValServer = (valModules, options, callbacks) => {
13483
13577
  }
13484
13578
  };
13485
13579
  }
13486
- const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13487
- if (refusal !== null) {
13488
- return {
13489
- status: refusal.status,
13490
- json: {
13491
- message: refusal.message
13492
- }
13493
- };
13580
+ // A named group is checked here as well as by the content API. No name
13581
+ // is the caller's own open group, which the content API resolves from
13582
+ // the profile it is sent — there is nothing here to check it against.
13583
+ if (patchGroupId !== undefined) {
13584
+ const refusal = await refuseUnlessOwn(serverOps, patchGroupId, auth.id);
13585
+ if (refusal !== null) {
13586
+ return {
13587
+ status: refusal.status,
13588
+ json: {
13589
+ message: refusal.message
13590
+ }
13591
+ };
13592
+ }
13494
13593
  }
13495
13594
  const res = await serverOps.unstagePatches(patchGroupId, patchIds, withPatchIds, auth.id);
13496
13595
  if (res.error) {
@@ -13504,8 +13603,11 @@ const ValServer = (valModules, options, callbacks) => {
13504
13603
  return {
13505
13604
  status: 200,
13506
13605
  json: {
13507
- patchGroupId,
13508
- patchIds: res.patchIds
13606
+ patchGroupId: res.patchGroupId,
13607
+ patchIds: res.patchIds,
13608
+ ...(res.headVersion !== undefined ? {
13609
+ headVersion: res.headVersion
13610
+ } : {})
13509
13611
  }
13510
13612
  };
13511
13613
  }
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.139.0",
19
+ "version": "0.139.1",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0",
@@ -30,9 +30,9 @@
30
30
  "typescript": "^6.0.3",
31
31
  "zod": "^4.4.3",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.139.0",
34
- "@valbuild/ui": "0.139.0",
35
- "@valbuild/shared": "0.139.0"
33
+ "@valbuild/core": "0.139.1",
34
+ "@valbuild/shared": "0.139.1",
35
+ "@valbuild/ui": "0.139.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"