primitive-admin 1.1.0-alpha.85 → 1.1.0-alpha.87

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.
Files changed (98) hide show
  1. package/README.md +34 -0
  2. package/assets/skill/skills/primitive-platform/SKILL.md +37 -1
  3. package/dist/src/commands/analytics.js +17 -5
  4. package/dist/src/commands/analytics.js.map +1 -1
  5. package/dist/src/commands/auth-sessions.d.ts +7 -0
  6. package/dist/src/commands/auth-sessions.js +144 -0
  7. package/dist/src/commands/auth-sessions.js.map +1 -0
  8. package/dist/src/commands/auth.js +47 -8
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/collections.js +648 -1
  11. package/dist/src/commands/collections.js.map +1 -1
  12. package/dist/src/commands/database-type-configs.js +1 -1
  13. package/dist/src/commands/database-type-configs.js.map +1 -1
  14. package/dist/src/commands/databases.js +11 -2
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/documents.d.ts +20 -0
  17. package/dist/src/commands/documents.js +213 -17
  18. package/dist/src/commands/documents.js.map +1 -1
  19. package/dist/src/commands/functions.js +56 -5
  20. package/dist/src/commands/functions.js.map +1 -1
  21. package/dist/src/commands/integrations.js +4 -0
  22. package/dist/src/commands/integrations.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +83 -41
  24. package/dist/src/commands/sync.js +553 -81
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/commands/users.js +3 -1
  27. package/dist/src/commands/users.js.map +1 -1
  28. package/dist/src/lib/api-client.d.ts +111 -7
  29. package/dist/src/lib/api-client.js +92 -20
  30. package/dist/src/lib/api-client.js.map +1 -1
  31. package/dist/src/lib/app-settings-descriptor.d.ts +1 -1
  32. package/dist/src/lib/app-settings-descriptor.js +0 -1
  33. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  34. package/dist/src/lib/collection-export.d.ts +184 -0
  35. package/dist/src/lib/collection-export.js +252 -0
  36. package/dist/src/lib/collection-export.js.map +1 -0
  37. package/dist/src/lib/config-object-descriptor.js +17 -3
  38. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  39. package/dist/src/lib/config-payload.d.ts +8 -1
  40. package/dist/src/lib/config-payload.js +29 -9
  41. package/dist/src/lib/config-payload.js.map +1 -1
  42. package/dist/src/lib/config-surface.js +11 -7
  43. package/dist/src/lib/config-surface.js.map +1 -1
  44. package/dist/src/lib/deprecation.d.ts +22 -0
  45. package/dist/src/lib/deprecation.js +43 -0
  46. package/dist/src/lib/deprecation.js.map +1 -0
  47. package/dist/src/lib/env-resolver-core.js +10 -3
  48. package/dist/src/lib/env-resolver-core.js.map +1 -1
  49. package/dist/src/lib/function-db-types.d.ts +14 -3
  50. package/dist/src/lib/function-db-types.js +55 -11
  51. package/dist/src/lib/function-db-types.js.map +1 -1
  52. package/dist/src/lib/function-document-types.d.ts +13 -2
  53. package/dist/src/lib/function-document-types.js +79 -30
  54. package/dist/src/lib/function-document-types.js.map +1 -1
  55. package/dist/src/lib/function-trigger-listing.d.ts +27 -0
  56. package/dist/src/lib/function-trigger-listing.js +70 -0
  57. package/dist/src/lib/function-trigger-listing.js.map +1 -0
  58. package/dist/src/lib/generated-config-surfaces.d.ts +540 -10
  59. package/dist/src/lib/generated-config-surfaces.js +1915 -173
  60. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  61. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  62. package/dist/src/lib/generated-sdk-types.js +1 -1
  63. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  64. package/dist/src/lib/log-inspection.d.ts +18 -1
  65. package/dist/src/lib/log-inspection.js +5 -0
  66. package/dist/src/lib/log-inspection.js.map +1 -1
  67. package/dist/src/lib/logout-admin-session.d.ts +33 -0
  68. package/dist/src/lib/logout-admin-session.js +70 -0
  69. package/dist/src/lib/logout-admin-session.js.map +1 -0
  70. package/dist/src/lib/prompt-cost-format.d.ts +11 -0
  71. package/dist/src/lib/prompt-cost-format.js +41 -0
  72. package/dist/src/lib/prompt-cost-format.js.map +1 -0
  73. package/dist/src/lib/prompt-schema-codegen.d.ts +53 -0
  74. package/dist/src/lib/prompt-schema-codegen.js +253 -3
  75. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  76. package/dist/src/lib/refresh-admin-credentials.d.ts +9 -1
  77. package/dist/src/lib/refresh-admin-credentials.js +22 -2
  78. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  79. package/dist/src/lib/storage-pending-retry.d.ts +26 -0
  80. package/dist/src/lib/storage-pending-retry.js +42 -0
  81. package/dist/src/lib/storage-pending-retry.js.map +1 -0
  82. package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
  83. package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
  84. package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
  85. package/dist/src/lib/swift-codegen/banners.d.ts +6 -0
  86. package/dist/src/lib/swift-codegen/banners.js +6 -0
  87. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  88. package/dist/src/lib/swift-codegen/dbGenerator.js +11 -3
  89. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  90. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +7 -0
  91. package/dist/src/lib/swift-codegen/functionGenerator.js +41 -3
  92. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  93. package/dist/src/lib/swift-codegen/generator.js +8 -2
  94. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  95. package/dist/src/lib/workflow-usage.d.ts +11 -13
  96. package/dist/src/lib/workflow-usage.js +12 -14
  97. package/dist/src/lib/workflow-usage.js.map +1 -1
  98. package/package.json +2 -2
@@ -1,10 +1,13 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { ApiClient } from "../lib/api-client.js";
2
4
  import { resolveAppId } from "../lib/config.js";
3
- import { success, error, info, keyValue, result as printResult, formatTable, formatId, formatDate, json, } from "../lib/output.js";
5
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, json, flushOutput, } from "../lib/output.js";
4
6
  import { confirmPrompt } from "../lib/confirm-prompt.js";
5
7
  import { pageCursorOption, pageLimitOption, parsePageLimit, printEmptyPage, printPageHint, } from "../lib/list-options.js";
6
8
  import { normalizeCliListEnvelope, wholeListEnvelope, } from "../lib/paginate.js";
7
9
  import { resolveOwnerUserId } from "../lib/resolve-owner.js";
10
+ import { COLLECTION_EXPORT_FILENAME, COLLECTION_EXPORT_VERSION, duplicateNameSources, emptyImportSummary, grantDisposition, grantUpdateLine, importExitCode, planCollection, readCollectionExportFile, serverReason, } from "../lib/collection-export.js";
8
11
  export function registerCollectionsCommands(program) {
9
12
  const collections = program
10
13
  .command("collections")
@@ -631,5 +634,649 @@ Ownership:
631
634
  process.exit(1);
632
635
  }
633
636
  });
637
+ registerCollectionsExport(collections);
638
+ registerCollectionsImport(collections);
639
+ }
640
+ // ---------------------------------------------------------------------------
641
+ // Export (#3643)
642
+ // ---------------------------------------------------------------------------
643
+ /**
644
+ * `primitive collections export` — the collections half of a migration.
645
+ *
646
+ * `documents export-all` writes `manifest.json` and a directory per document;
647
+ * this writes `collections.json` beside them, so one directory carries the
648
+ * whole app. Owner and member identities travel as EMAILS wherever the server
649
+ * can resolve one: users are app-scoped (`models.yaml`), so the same person is
650
+ * a different userId in every app and a recorded source userId means nothing
651
+ * in the target.
652
+ */
653
+ function registerCollectionsExport(collections) {
654
+ collections
655
+ .command("export")
656
+ .description("Export every collection in an app (name, owner, members, documents, group grants)")
657
+ .option("--app <app-id>", "App ID")
658
+ .option("--output <dir>", "Output directory", "./primitive-export")
659
+ .option("--json", "Output as JSON")
660
+ .addHelpText("after", `
661
+ Admin only: the export reads the app-wide collection listing.
662
+
663
+ It writes <output>/collections.json, beside the manifest.json and documents/
664
+ that 'documents export-all' writes, so one directory is one app's migration.
665
+ Import it with 'primitive collections import' AFTER 'documents import', which
666
+ preserves document ids.
667
+
668
+ Owner and member identities are recorded as emails wherever the server can
669
+ resolve one — users are app-scoped, so a source app's userId means nothing in
670
+ the target. A userId with no resolvable email is recorded as-is and will only
671
+ import back into the same app.
672
+
673
+ Not exported: collection ids (the server mints new ones), collection resource
674
+ metadata, the groups themselves, pending member invitations.
675
+ `)
676
+ .action(async (options) => {
677
+ const resolvedAppId = resolveAppId(undefined, options);
678
+ const client = new ApiClient();
679
+ try {
680
+ await exportCollections(client, resolvedAppId, options);
681
+ }
682
+ catch (err) {
683
+ error(err.message);
684
+ await flushOutput();
685
+ process.exit(1);
686
+ }
687
+ });
688
+ }
689
+ /** Follow every page of the admin collection listing. */
690
+ async function readAllCollections(client, appId) {
691
+ const rows = [];
692
+ let cursor;
693
+ do {
694
+ const page = await client.listAllCollections(appId, {
695
+ limit: 200,
696
+ ...(cursor ? { cursor } : {}),
697
+ });
698
+ rows.push(...(page.items ?? []));
699
+ cursor = page.nextCursor ?? undefined;
700
+ } while (cursor);
701
+ return rows;
702
+ }
703
+ /** Follow every page of one collection's document list. */
704
+ async function readAllCollectionDocuments(client, appId, collectionId) {
705
+ const rows = [];
706
+ let cursor;
707
+ do {
708
+ const page = await client.listCollectionDocuments(appId, collectionId, {
709
+ limit: 200,
710
+ ...(cursor ? { cursor } : {}),
711
+ });
712
+ for (const item of page.items ?? []) {
713
+ rows.push({
714
+ documentId: item.documentId,
715
+ addedAt: item.addedAt ?? null,
716
+ });
717
+ }
718
+ cursor = page.nextCursor ?? undefined;
719
+ } while (cursor);
720
+ return rows;
721
+ }
722
+ async function exportCollections(client, appId, options) {
723
+ const rows = await readAllCollections(client, appId);
724
+ // One lookup per DISTINCT userId across the whole run, cached: a busy app's
725
+ // owner appears on every collection it owns and again in each one's members.
726
+ const emailCache = new Map();
727
+ const emailFor = async (userId) => {
728
+ if (emailCache.has(userId))
729
+ return emailCache.get(userId);
730
+ let email;
731
+ try {
732
+ const found = await client.listUsers(appId, { userId, limit: 1 });
733
+ email = (found.items ?? [])[0]?.email || undefined;
734
+ }
735
+ catch {
736
+ // An unresolvable email is not a reason to lose the collection: the
737
+ // userId is still recorded, and a same-app import can still use it.
738
+ email = undefined;
739
+ }
740
+ emailCache.set(userId, email);
741
+ return email;
742
+ };
743
+ const identity = async (userId) => {
744
+ const email = await emailFor(userId);
745
+ return email ? { userId, email } : { userId };
746
+ };
747
+ const exported = [];
748
+ let skipped = 0;
749
+ for (const row of rows) {
750
+ const collectionId = row.collectionId;
751
+ try {
752
+ const documents = await readAllCollectionDocuments(client, appId, collectionId);
753
+ const access = await client.getCollectionAccess(appId, collectionId);
754
+ const members = [];
755
+ for (const m of access?.members ?? []) {
756
+ members.push({
757
+ ...(await identity(m.userId)),
758
+ permission: m.permission,
759
+ });
760
+ }
761
+ const groups = (access?.groups ?? [])
762
+ // The server already filters system groups out of this response; a
763
+ // `_`-prefixed type could never be re-granted anyway (it is reserved).
764
+ .filter((g) => !String(g.groupType).startsWith("_"))
765
+ .map((g) => ({
766
+ groupType: g.groupType,
767
+ groupId: g.groupId,
768
+ permission: g.permission,
769
+ }));
770
+ exported.push({
771
+ collectionId,
772
+ name: row.name,
773
+ description: row.description ?? null,
774
+ collectionType: row.collectionType ?? null,
775
+ contextId: row.contextId ?? null,
776
+ createdAt: row.createdAt ?? null,
777
+ owner: row.createdBy ? await identity(row.createdBy) : null,
778
+ documents,
779
+ members,
780
+ groups,
781
+ });
782
+ }
783
+ catch (err) {
784
+ skipped += 1;
785
+ warn(`Skipping collection "${row.name}" (${collectionId}): ${err.message}`);
786
+ }
787
+ }
788
+ const filePath = path.join(options.output, COLLECTION_EXPORT_FILENAME);
789
+ // Each run REPLACES the file. A collection the previous one listed and this
790
+ // one does not becomes unreachable through the file an import reads, so say
791
+ // so — the same warning `documents export-all` gives for its manifest.
792
+ if (fs.existsSync(filePath)) {
793
+ try {
794
+ const previous = JSON.parse(fs.readFileSync(filePath, "utf-8"));
795
+ const kept = new Set(exported.map((c) => c.collectionId));
796
+ const dropped = (Array.isArray(previous?.collections) ? previous.collections : []).filter((c) => !kept.has(c?.collectionId));
797
+ if (dropped.length > 0) {
798
+ warn(`The ${COLLECTION_EXPORT_FILENAME} already in ${options.output} lists ` +
799
+ `${dropped.length} collection(s) this export does not: ` +
800
+ `${dropped.map((c) => c?.name ?? c?.collectionId).join(", ")}. ` +
801
+ `Replacing it leaves them unreachable through the file an import reads.`);
802
+ }
803
+ }
804
+ catch {
805
+ // An unreadable previous file is not a reason to refuse the export.
806
+ }
807
+ }
808
+ const file = {
809
+ version: COLLECTION_EXPORT_VERSION,
810
+ exportedAt: new Date().toISOString(),
811
+ sourceAppId: appId,
812
+ collectionCount: exported.length,
813
+ collections: exported,
814
+ };
815
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
816
+ fs.writeFileSync(filePath, JSON.stringify(file, null, 2));
817
+ if (options.json) {
818
+ // Counts and the path, not the bodies: the file is where those live.
819
+ json({
820
+ version: file.version,
821
+ exportedAt: file.exportedAt,
822
+ sourceAppId: file.sourceAppId,
823
+ collectionCount: file.collectionCount,
824
+ skipped,
825
+ path: filePath,
826
+ });
827
+ return;
828
+ }
829
+ success(`Exported ${exported.length} collection(s) to ${filePath}`);
830
+ }
831
+ // ---------------------------------------------------------------------------
832
+ // Import (#3643)
833
+ // ---------------------------------------------------------------------------
834
+ /**
835
+ * `primitive collections import` — the other half of the migration.
836
+ *
837
+ * Two phases, in this order and never interleaved:
838
+ *
839
+ * 1. RESOLVE, reads only. Every owner, member, document and group the file
840
+ * names is looked up in the target app and the plan is decided from the
841
+ * answers, before anything at all is written. Only a DEFINITIVE absence
842
+ * (a 404, or `exists: false` from the email lookup) reads as "not here";
843
+ * any other failure fails the run with the collection and the item named,
844
+ * exactly as `documents import` decides its root-document plans (#3096).
845
+ * 2. APPLY, in the order create → group grants → members → documents. Grants
846
+ * go first ON PURPOSE: `addDocument` materializes every existing grant
847
+ * onto a newly added document, so a fresh collection's access never
848
+ * depends on the grant's own fan-out over the document list (F4).
849
+ */
850
+ function registerCollectionsImport(collections) {
851
+ collections
852
+ .command("import")
853
+ .description("Recreate collections from a collections.json export (owner, members, documents, group grants)")
854
+ .argument("<path>", "Export directory holding collections.json, or the file itself")
855
+ .option("--app <app-id>", "App ID")
856
+ .option("--owner <userId-or-email>", "Own every imported collection with this user, instead of the owner each one records")
857
+ .option("--overwrite", "Merge into a collection that already carries the same name")
858
+ .option("--dry-run", "Show the plan and the problems without writing anything")
859
+ .option("--json", "Output as JSON")
860
+ .addHelpText("after", `
861
+ For admin tokens. Assigning an owner is admin-only, so under an app-user token
862
+ the server creates every collection owned by the caller and each one whose
863
+ recorded owner is somebody else is reported as an 'owner' problem.
864
+
865
+ Run 'primitive documents import' FIRST: it preserves document ids, which is
866
+ what the collections file refers to.
867
+
868
+ Owners and members are resolved by EMAIL — users are app-scoped, so a source
869
+ app's userId means nothing here. A recorded userId is used only when the file
870
+ came from this same app.
871
+
872
+ Matching is by name. Without --overwrite a name already in the target is
873
+ skipped; with it, the collection is merged — but only when its owner, type and
874
+ context id match, since none of those can be changed after creation.
875
+
876
+ A group grant the target already holds at another level is UPDATED to the level
877
+ the file records, and what the group can reach follows the change. On a
878
+ collection holding more than 200 documents the server refuses a collection-wide
879
+ group change whole (COLLECTION_FANOUT_LIMIT): that is reported as a 'group'
880
+ problem, and the rest of the collection is still merged.
881
+
882
+ Exit code 1 when any per-item problem was reported. A skip is not a problem.
883
+ `)
884
+ .action(async (importPath, options) => {
885
+ const resolvedAppId = resolveAppId(undefined, options);
886
+ const client = new ApiClient();
887
+ let code = 0;
888
+ try {
889
+ code = await importCollections(client, resolvedAppId, importPath, options);
890
+ }
891
+ catch (err) {
892
+ error(err.message);
893
+ code = 1;
894
+ }
895
+ await flushOutput();
896
+ if (code !== 0)
897
+ process.exit(code);
898
+ });
899
+ }
900
+ /** A definitive "not here", as opposed to a read that simply failed. */
901
+ function isNotFound(err) {
902
+ return err?.statusCode === 404;
903
+ }
904
+ async function importCollections(client, appId, importPath, options) {
905
+ const file = readCollectionExportFile(importPath);
906
+ // A source userId is only meaningful when the file came from THIS app —
907
+ // users are tenant-scoped, so the same person has a different userId in
908
+ // every app (`models.yaml`).
909
+ const sameApp = file.sourceAppId === appId;
910
+ /** userId ← recorded userId: the admin read, cached, one per distinct id. */
911
+ const byUserId = new Map();
912
+ const resolveRecordedUserId = async (userId) => {
913
+ if (byUserId.has(userId))
914
+ return byUserId.get(userId);
915
+ const found = await client.listUsers(appId, { userId, limit: 1 });
916
+ const hit = (found.items ?? [])[0];
917
+ // A row with no role is a user who is no longer in this app.
918
+ const resolved = hit && hit.role ? hit.userId : null;
919
+ byUserId.set(userId, resolved);
920
+ return resolved;
921
+ };
922
+ // `--owner` is resolved ONCE, before a single collection of the file is
923
+ // looked at: a name nobody answers to must fail the run with nothing written.
924
+ let explicitOwnerUserId;
925
+ if (options.owner) {
926
+ explicitOwnerUserId = await resolveOwnerUserId(client, appId, options.owner);
927
+ if (!options.owner.includes("@")) {
928
+ // `resolveOwnerUserId` hands a value with no "@" straight back — it is a
929
+ // userId as far as the flag is concerned, but nothing has yet checked
930
+ // that this app has such a member. Left unchecked, an id nobody answers
931
+ // to planned a create for every collection, `--dry-run` reported that
932
+ // plan as fine, and only the real run found out, one failed create at a
933
+ // time. The flag fails the run here instead, the way an unknown email
934
+ // already does.
935
+ let found;
936
+ try {
937
+ found = await resolveRecordedUserId(explicitOwnerUserId);
938
+ }
939
+ catch (err) {
940
+ throw new Error(`Checking --owner ${explicitOwnerUserId} against app ${appId} ` +
941
+ `failed: ${err?.message ?? err}. Nothing has been written.`);
942
+ }
943
+ if (!found) {
944
+ throw new Error(`No app user found with id "${explicitOwnerUserId}" in app ${appId}.`);
945
+ }
946
+ }
947
+ }
948
+ const problems = [];
949
+ const summary = emptyImportSummary();
950
+ // ── Resolve phase ──────────────────────────────────────────────────────
951
+ const targetByName = new Map();
952
+ for (const row of await readAllCollections(client, appId)) {
953
+ if (!targetByName.has(row.name))
954
+ targetByName.set(row.name, row);
955
+ }
956
+ /** userId ← email, cached: one lookup per distinct email per run. */
957
+ const byEmail = new Map();
958
+ const resolveEmail = async (email) => {
959
+ if (byEmail.has(email))
960
+ return byEmail.get(email);
961
+ const lookup = await client.lookupUserByEmail(appId, email);
962
+ const userId = lookup?.exists && lookup.user ? lookup.user.userId : null;
963
+ byEmail.set(email, userId);
964
+ return userId;
965
+ };
966
+ /**
967
+ * One identity from the file to a userId in the target app.
968
+ *
969
+ * `{ userId: null, reason }` is a definitive absence to report; anything the
970
+ * reads could not settle has already thrown by the time this returns.
971
+ */
972
+ const resolveIdentity = async (identity) => {
973
+ if (!identity) {
974
+ return { userId: null, reason: "the export records no identity for it", id: "(none)" };
975
+ }
976
+ const id = identity.email || identity.userId || "(none)";
977
+ if (identity.email) {
978
+ const userId = await resolveEmail(identity.email);
979
+ return userId
980
+ ? { userId, id }
981
+ : {
982
+ userId: null,
983
+ id,
984
+ reason: `no user with email "${identity.email}" is a member of ${appId}`,
985
+ };
986
+ }
987
+ if (identity.userId && sameApp) {
988
+ const userId = await resolveRecordedUserId(identity.userId);
989
+ return userId
990
+ ? { userId, id }
991
+ : {
992
+ userId: null,
993
+ id,
994
+ reason: `no user with id ${identity.userId} is a member of ${appId}`,
995
+ };
996
+ }
997
+ return {
998
+ userId: null,
999
+ id,
1000
+ reason: `it records only a userId from app ${file.sourceAppId || "(unknown)"}, ` +
1001
+ `and users are scoped to their app — the same person has a different ` +
1002
+ `userId here, so only an email can identify them across apps`,
1003
+ };
1004
+ };
1005
+ const duplicates = duplicateNameSources(file.collections);
1006
+ const planned = [];
1007
+ for (const source of file.collections) {
1008
+ const note = (kind, id, reason) => {
1009
+ problems.push({
1010
+ sourceCollectionId: source.collectionId,
1011
+ name: source.name,
1012
+ kind,
1013
+ id,
1014
+ reason,
1015
+ });
1016
+ warn(`${source.name}: ${kind} ${id} — ${reason}`);
1017
+ };
1018
+ /** Any read that is not a definitive absence stops the whole run. */
1019
+ const fatal = (item, err) => {
1020
+ throw new Error(`Reading ${item} for collection "${source.name}" ` +
1021
+ `(${source.collectionId}) failed: ${err?.message ?? err}. ` +
1022
+ `Nothing has been written.`);
1023
+ };
1024
+ let ownerUserId = explicitOwnerUserId ?? null;
1025
+ let ownerReason;
1026
+ if (!ownerUserId) {
1027
+ try {
1028
+ const resolved = await resolveIdentity(source.owner);
1029
+ ownerUserId = resolved.userId;
1030
+ ownerReason = resolved.reason;
1031
+ }
1032
+ catch (err) {
1033
+ fatal(`owner ${source.owner?.email ?? source.owner?.userId ?? ""}`, err);
1034
+ }
1035
+ }
1036
+ const existingRow = targetByName.get(source.name);
1037
+ const row = planCollection(source, {
1038
+ ownerUserId,
1039
+ ownerReason,
1040
+ existing: existingRow
1041
+ ? {
1042
+ collectionId: existingRow.collectionId,
1043
+ createdBy: existingRow.createdBy,
1044
+ collectionType: existingRow.collectionType ?? null,
1045
+ contextId: existingRow.contextId ?? null,
1046
+ }
1047
+ : null,
1048
+ duplicateOfSourceId: duplicates.get(source.collectionId) ?? null,
1049
+ }, { overwrite: Boolean(options.overwrite) });
1050
+ if (row.problem) {
1051
+ note(row.problem.kind, row.problem.id, row.problem.reason);
1052
+ }
1053
+ if (row.action === "refused" || row.action === "skip") {
1054
+ planned.push({ row, source, grants: [], members: [], documents: [] });
1055
+ continue;
1056
+ }
1057
+ // What the target already grants, so a merge can compare levels.
1058
+ let targetGrants = [];
1059
+ if (row.action === "merge" && row.targetCollectionId) {
1060
+ try {
1061
+ const access = await client.getCollectionAccess(appId, row.targetCollectionId);
1062
+ targetGrants = (access?.groups ?? []).map((g) => ({
1063
+ groupType: g.groupType,
1064
+ groupId: g.groupId,
1065
+ permission: g.permission,
1066
+ }));
1067
+ }
1068
+ catch (err) {
1069
+ fatal(`the access of ${row.targetCollectionId}`, err);
1070
+ }
1071
+ }
1072
+ const grants = [];
1073
+ for (const grant of source.groups ?? []) {
1074
+ const label = `${grant.groupType}/${grant.groupId}`;
1075
+ // The server reserves the `_` prefix for its own system groups; an
1076
+ // export never writes one, and re-granting one is a 400.
1077
+ if (String(grant.groupType).startsWith("_")) {
1078
+ warn(`${source.name}: skipping the reserved group type ${grant.groupType} ` +
1079
+ `(${label}); the server manages those itself.`);
1080
+ continue;
1081
+ }
1082
+ try {
1083
+ await client.getGroup(appId, grant.groupType, grant.groupId);
1084
+ }
1085
+ catch (err) {
1086
+ if (isNotFound(err)) {
1087
+ note("group", label, `no group ${label} exists in ${appId}`);
1088
+ continue;
1089
+ }
1090
+ fatal(`group ${label}`, err);
1091
+ }
1092
+ const disposition = grantDisposition(grant, targetGrants);
1093
+ const existing = targetGrants.find((g) => g.groupType === grant.groupType && g.groupId === grant.groupId);
1094
+ grants.push({
1095
+ grant,
1096
+ disposition,
1097
+ ...(existing ? { existingPermission: existing.permission } : {}),
1098
+ });
1099
+ }
1100
+ const members = [];
1101
+ for (const member of source.members ?? []) {
1102
+ let resolved;
1103
+ try {
1104
+ resolved = await resolveIdentity(member);
1105
+ }
1106
+ catch (err) {
1107
+ fatal(`member ${member.email ?? member.userId}`, err);
1108
+ continue;
1109
+ }
1110
+ if (!resolved.userId) {
1111
+ note("member", resolved.id, resolved.reason);
1112
+ continue;
1113
+ }
1114
+ members.push({ userId: resolved.userId, permission: member.permission });
1115
+ }
1116
+ const documents = [];
1117
+ for (const document of source.documents ?? []) {
1118
+ try {
1119
+ await client.getDocument(appId, document.documentId);
1120
+ }
1121
+ catch (err) {
1122
+ if (isNotFound(err)) {
1123
+ note("document", document.documentId, `no document ${document.documentId} exists in ${appId}; ` +
1124
+ `run 'primitive documents import' first — it preserves ids`);
1125
+ continue;
1126
+ }
1127
+ fatal(`document ${document.documentId}`, err);
1128
+ }
1129
+ documents.push(document.documentId);
1130
+ }
1131
+ planned.push({ row, source, grants, members, documents });
1132
+ }
1133
+ // ── Apply phase ────────────────────────────────────────────────────────
1134
+ for (const entry of planned) {
1135
+ const { row, source } = entry;
1136
+ const note = (kind, id, reason) => {
1137
+ problems.push({
1138
+ sourceCollectionId: source.collectionId,
1139
+ name: source.name,
1140
+ kind,
1141
+ id,
1142
+ reason,
1143
+ });
1144
+ warn(`${source.name}: ${kind} ${id} — ${reason}`);
1145
+ };
1146
+ if (row.action === "refused") {
1147
+ summary.refused += 1;
1148
+ info(`Refusing ${source.name}: ${row.reason}`);
1149
+ continue;
1150
+ }
1151
+ if (row.action === "skip") {
1152
+ summary.skipped += 1;
1153
+ info(`Skipping ${source.name}: ${row.reason}`);
1154
+ continue;
1155
+ }
1156
+ if (options.dryRun) {
1157
+ // The plan, and nothing else: not one write is issued.
1158
+ if (row.action === "create") {
1159
+ summary.created += 1;
1160
+ info(`[dry-run] Would create ${source.name}`);
1161
+ }
1162
+ else {
1163
+ summary.merged += 1;
1164
+ info(`[dry-run] Would merge ${source.name} into ${row.targetCollectionId}`);
1165
+ }
1166
+ for (const { grant, disposition, existingPermission } of entry.grants) {
1167
+ if (disposition === "already-applied")
1168
+ continue;
1169
+ const label = `${grant.groupType}/${grant.groupId}`;
1170
+ info(disposition === "update"
1171
+ ? `[dry-run] Would update ${grantUpdateLine(label, existingPermission, grant.permission)}`
1172
+ : `[dry-run] Would grant ${label} ${grant.permission}`);
1173
+ summary.groupsGranted += 1;
1174
+ }
1175
+ summary.membersAdded += entry.members.length;
1176
+ summary.documentsAdded += entry.documents.length;
1177
+ continue;
1178
+ }
1179
+ let targetCollectionId = row.targetCollectionId;
1180
+ if (row.action === "create") {
1181
+ try {
1182
+ const created = await client.createCollection(appId, {
1183
+ name: source.name,
1184
+ ...(source.description ? { description: source.description } : {}),
1185
+ ...(source.collectionType ? { collectionType: source.collectionType } : {}),
1186
+ ...(source.contextId ? { contextId: source.contextId } : {}),
1187
+ createdBy: row.ownerUserId,
1188
+ });
1189
+ targetCollectionId = created.collectionId;
1190
+ row.targetCollectionId = targetCollectionId;
1191
+ summary.created += 1;
1192
+ info(`Created ${source.name} as ${targetCollectionId}`);
1193
+ // Whom the server CREDITED is the answer. A server predating #3641
1194
+ // ignores the field, and so does any server for a non-admin token.
1195
+ if (created.createdBy && created.createdBy !== row.ownerUserId) {
1196
+ note("owner", row.ownerUserId, `the server did not assign the owner (it credited ` +
1197
+ `${created.createdBy}); the token is not an admin one, or the ` +
1198
+ `server predates #3641`);
1199
+ }
1200
+ }
1201
+ catch (err) {
1202
+ note("collection", source.collectionId, serverReason(err));
1203
+ continue;
1204
+ }
1205
+ }
1206
+ else {
1207
+ summary.merged += 1;
1208
+ info(`Merged ${source.name} into ${targetCollectionId}`);
1209
+ }
1210
+ for (const { grant, disposition, existingPermission } of entry.grants) {
1211
+ if (disposition === "already-applied")
1212
+ continue;
1213
+ const label = `${grant.groupType}/${grant.groupId}`;
1214
+ try {
1215
+ await client.grantCollectionGroupPermission(appId, targetCollectionId, {
1216
+ groupType: grant.groupType,
1217
+ groupId: grant.groupId,
1218
+ permission: grant.permission,
1219
+ });
1220
+ summary.groupsGranted += 1;
1221
+ // A level change is worth saying out loud: it is the one grant write
1222
+ // that can take access away from people who already had it.
1223
+ if (disposition === "update") {
1224
+ info(`Updated ${grantUpdateLine(label, existingPermission, grant.permission)}`);
1225
+ }
1226
+ }
1227
+ catch (err) {
1228
+ note("group", label, serverReason(err));
1229
+ }
1230
+ }
1231
+ for (const member of entry.members) {
1232
+ try {
1233
+ const result = await client.addCollectionMember(appId, targetCollectionId, {
1234
+ userId: member.userId,
1235
+ permission: member.permission,
1236
+ });
1237
+ // `already_member` is the server saying it was already so — including
1238
+ // the owner's own `_col-writer` enrolment, which every export records.
1239
+ if (result?.status !== "already_member")
1240
+ summary.membersAdded += 1;
1241
+ }
1242
+ catch (err) {
1243
+ note("member", member.userId, serverReason(err));
1244
+ }
1245
+ }
1246
+ for (const documentId of entry.documents) {
1247
+ try {
1248
+ await client.addCollectionDocument(appId, targetCollectionId, { documentId });
1249
+ summary.documentsAdded += 1;
1250
+ }
1251
+ catch (err) {
1252
+ // 409 is "already in this collection": applied, not a problem.
1253
+ if (err?.statusCode === 409)
1254
+ continue;
1255
+ note("document", documentId, serverReason(err));
1256
+ }
1257
+ }
1258
+ }
1259
+ if (options.json) {
1260
+ json({
1261
+ ...summary,
1262
+ collections: planned.map(({ row }) => ({
1263
+ sourceCollectionId: row.sourceCollectionId,
1264
+ name: row.name,
1265
+ action: row.action,
1266
+ ...(row.targetCollectionId
1267
+ ? { targetCollectionId: row.targetCollectionId }
1268
+ : {}),
1269
+ ...(row.reason ? { reason: row.reason } : {}),
1270
+ })),
1271
+ problems,
1272
+ });
1273
+ }
1274
+ else {
1275
+ success(`${summary.created} created, ${summary.merged} merged, ` +
1276
+ `${summary.skipped} skipped, ${summary.refused} refused; ` +
1277
+ `${summary.groupsGranted} group grant(s), ${summary.membersAdded} member(s), ` +
1278
+ `${summary.documentsAdded} document(s) added; ${problems.length} problem(s).`);
1279
+ }
1280
+ return importExitCode(problems);
634
1281
  }
635
1282
  //# sourceMappingURL=collections.js.map