@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.321

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 (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +12 -8
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -18
  22. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +14 -4
  23. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +29 -14
  24. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +11 -1
  25. package/src/util/Util_AwsError.res +56 -0
  26. package/src/util/Util_AwsError.res.mjs +59 -0
  27. package/src/util/Util_ShellConfig.res +28 -0
  28. package/src/util/Util_ShellConfig.res.mjs +22 -0
  29. package/tests/AppSync_RetirementNarrowingTest.res +10 -12
  30. package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
  31. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  32. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  33. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  34. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  35. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  36. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  37. package/tests/AwsErrorFixtures.mjs +16 -0
  38. package/tests/ProvisionIdentityTest.res +109 -0
  39. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  40. package/tests/QueryDbOwnerIndexTableTest.res +53 -0
  41. package/tests/QueryDbOwnerIndexTableTest.res.mjs +50 -0
  42. package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
  43. package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
  44. package/tests/Util_AwsErrorTest.res +127 -0
  45. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  46. package/tests/Util_ShellConfigTest.res +51 -0
  47. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -0,0 +1,97 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "../src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs";
5
+
6
+ globalThis.describe("Auth_ActiveRoleStore_Schema.derivedStoreName", () => {
7
+ globalThis.test("names the store after the provider whose trigger reads it", () => {
8
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_CQTwafSeX")).toBe("ReventlessActiveRoleStore-eu-west-1_CQTwafSeX");
9
+ });
10
+ globalThis.test("two deployments on one provider derive the same store", () => {
11
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_Shared")).toBe(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_Shared"));
12
+ });
13
+ globalThis.test("different providers do not collide", () => {
14
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_A") === Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_B")).toBe(false);
15
+ });
16
+ globalThis.test("the derived name is a legal DynamoDB table name", () => {
17
+ let name = Auth_ActiveRoleStore_Schema$ReventlessAws.derivedStoreName("eu-west-1_CQTwafSeX");
18
+ globalThis.expect([
19
+ new RegExp("^[A-Za-z0-9_.-]+$").test(name),
20
+ name.length >= 3 && name.length <= 255
21
+ ]).toEqual([
22
+ true,
23
+ true
24
+ ]);
25
+ });
26
+ });
27
+
28
+ globalThis.describe("Auth_ActiveRoleStore_Schema.keySchemaRefusal", () => {
29
+ let store = "ReventlessActiveRoleStore-eu-west-1_x";
30
+ globalThis.test("the expected pair is adopted without complaint", () => {
31
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
32
+ [
33
+ "id",
34
+ "HASH"
35
+ ],
36
+ [
37
+ "clientId",
38
+ "RANGE"
39
+ ]
40
+ ])).toEqual(undefined);
41
+ });
42
+ globalThis.test("order is not part of the comparison", () => {
43
+ globalThis.expect(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
44
+ [
45
+ "clientId",
46
+ "RANGE"
47
+ ],
48
+ [
49
+ "id",
50
+ "HASH"
51
+ ]
52
+ ])).toEqual(undefined);
53
+ });
54
+ globalThis.test("the pre-clientId store is refused rather than adopted", () => {
55
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [[
56
+ "id",
57
+ "HASH"
58
+ ]]))).toBe(true);
59
+ });
60
+ globalThis.test("the refusal names the table, what it found, and what is needed", () => {
61
+ let message = Stdlib_Option.getOr(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [[
62
+ "id",
63
+ "HASH"
64
+ ]]), "");
65
+ globalThis.expect([
66
+ message.includes(store),
67
+ message.includes("id:HASH"),
68
+ message.includes("clientId:RANGE")
69
+ ]).toEqual([
70
+ true,
71
+ true,
72
+ true
73
+ ]);
74
+ });
75
+ globalThis.test("a table keyed on something else entirely is refused", () => {
76
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, [
77
+ [
78
+ "pk",
79
+ "HASH"
80
+ ],
81
+ [
82
+ "sk",
83
+ "RANGE"
84
+ ]
85
+ ]))).toBe(true);
86
+ });
87
+ globalThis.test("an empty key schema is refused, not treated as a match", () => {
88
+ globalThis.expect(Stdlib_Option.isSome(Auth_ActiveRoleStore_Schema$ReventlessAws.keySchemaRefusal(store, []))).toBe(true);
89
+ });
90
+ });
91
+
92
+ let Schema;
93
+
94
+ export {
95
+ Schema,
96
+ }
97
+ /* Not a pure module */
@@ -0,0 +1,16 @@
1
+ // Throws the shape an AWS SDK v3 client actually throws, so a test catches it
2
+ // through the same path the real code does.
3
+ //
4
+ // It has to *throw* rather than return: ReScript's `exn` for a JS error is the
5
+ // wrapper its own catch builds, not the bare `Error` object. A fixture that
6
+ // returned one would be a different value than any real call produces, and a test
7
+ // built on it would prove nothing about the code that catches them.
8
+ //
9
+ // Here rather than in a `%raw` inside the test, per the repo convention that
10
+ // untyped reflection lives in a companion `.mjs` bound through `@module`.
11
+
12
+ export const throwAwsError = (name, message) => {
13
+ const error = new Error(message)
14
+ error.name = name
15
+ throw error
16
+ }
@@ -0,0 +1,109 @@
1
+ open JestGlobals
2
+
3
+ // Argument handling for the operator script. Testable at all only because the
4
+ // module no longer calls `main` at the top level — `run-provision-identity.mjs`
5
+ // does. While it ran itself on import, nothing in here could be reached, and
6
+ // that is how a guard that could never match shipped in the same file.
7
+ //
8
+ // What these cover is the one thing this script does that is hard to undo:
9
+ // deciding whether to *adopt* a user pool or *create* one.
10
+
11
+ module Provision = ProvisionIdentity
12
+
13
+ describe("ProvisionIdentity.parseArgs", () => {
14
+ testSync("no arguments means adopt-or-create the default name", () =>
15
+ expect(Provision.parseArgs([]))->toEqual(
16
+ Ok({
17
+ Provision.poolName: Provision.defaultPoolName,
18
+ providerId: None,
19
+ help: false,
20
+ }),
21
+ )
22
+ )
23
+
24
+ testSync("--provider-id names an existing pool", () =>
25
+ expect(Provision.parseArgs(["--provider-id", "eu-west-1_AbCdEfGhI"]))->toEqual(
26
+ Ok({
27
+ Provision.poolName: Provision.defaultPoolName,
28
+ providerId: Some("eu-west-1_AbCdEfGhI"),
29
+ help: false,
30
+ }),
31
+ )
32
+ )
33
+
34
+ testSync("--name selects which pool to adopt by name", () =>
35
+ expect(Provision.parseArgs(["--name", "examples-dev"]))->toEqual(
36
+ Ok({Provision.poolName: "examples-dev", providerId: None, help: false}),
37
+ )
38
+ )
39
+
40
+ testSync("both together are accepted, and the id wins at resolve time", () =>
41
+ expect(Provision.parseArgs(["--name", "ignored", "--provider-id", "eu-west-1_x"]))->toEqual(
42
+ Ok({Provision.poolName: "ignored", providerId: Some("eu-west-1_x"), help: false}),
43
+ )
44
+ )
45
+
46
+ // 🚨 The reason unknown flags are refused rather than skipped. A misspelled
47
+ // `--provider-id` that parsed as "absent" would silently take the *create* path
48
+ // and stand up a second user pool beside the one the operator meant to extend.
49
+ testSync("a misspelled flag is refused, not ignored", () =>
50
+ expect(Provision.parseArgs(["--provider_id", "eu-west-1_x"]))->toEqual(
51
+ Error(`unknown argument "--provider_id"`),
52
+ )
53
+ )
54
+
55
+ // Same hazard from the other direction: a flag whose value was left off must
56
+ // not read as "no id given".
57
+ testSync("a flag with no value is refused", () =>
58
+ expect(Provision.parseArgs(["--provider-id"]))->toEqual(
59
+ Error("--provider-id needs a value"),
60
+ )
61
+ )
62
+
63
+ testSync("--name with no value is refused too", () =>
64
+ expect(Provision.parseArgs(["--name"]))->toEqual(Error("--name needs a value"))
65
+ )
66
+
67
+ testSync("--help and -h both ask for the usage text", () =>
68
+ expect((
69
+ Provision.parseArgs(["--help"])->Result.map(a => a.help),
70
+ Provision.parseArgs(["-h"])->Result.map(a => a.help),
71
+ ))->toEqual((Ok(true), Ok(true)))
72
+ )
73
+
74
+ // The first error stops the scan rather than the last one winning: an operator
75
+ // fixing what they are told about should not then meet a second surprise.
76
+ testSync("the first refusal is the one reported", () =>
77
+ expect(Provision.parseArgs(["--nope", "--also-nope"]))->toEqual(
78
+ Error(`unknown argument "--nope"`),
79
+ )
80
+ )
81
+ })
82
+
83
+ // The pool this script creates when it creates one. Asserted because a pool is
84
+ // the one thing here that outlives every stack — nothing in a `pulumi destroy`
85
+ // touches it — so its settings are not a detail an operator can revise cheaply
86
+ // once accounts exist in it.
87
+ describe("ProvisionIdentity.poolSettings", () => {
88
+ let settings = Provision.poolSettings(~poolName="MyIdentity")
89
+
90
+ testSync("carries the name it was asked for", () =>
91
+ expect(settings.poolName)->toBe("MyIdentity")
92
+ )
93
+
94
+ testSync("matches what auto mode declares: email sign-in, no MFA, admin-only", () =>
95
+ expect((
96
+ settings.usernameAttributes,
97
+ settings.mfaConfiguration,
98
+ settings.adminCreateUserConfig->Option.flatMap(c => c.allowAdminCreateUserOnly),
99
+ ))->toEqual((Some(["email"]), Some("OFF"), Some(true)))
100
+ )
101
+
102
+ testSync("keeps the 12-character password policy", () =>
103
+ expect(
104
+ settings.policies
105
+ ->Option.flatMap(p => p.passwordPolicy)
106
+ ->Option.flatMap(p => p.minimumLength),
107
+ )->toEqual(Some(12))
108
+ )
109
+ })
@@ -0,0 +1,132 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
5
+ import * as ProvisionIdentity$ReventlessAws from "../scripts/ProvisionIdentity.res.mjs";
6
+
7
+ globalThis.describe("ProvisionIdentity.parseArgs", () => {
8
+ globalThis.test("no arguments means adopt-or-create the default name", () => {
9
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([])).toEqual({
10
+ TAG: "Ok",
11
+ _0: {
12
+ poolName: ProvisionIdentity$ReventlessAws.defaultPoolName,
13
+ providerId: undefined,
14
+ help: false
15
+ }
16
+ });
17
+ });
18
+ globalThis.test("--provider-id names an existing pool", () => {
19
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
20
+ "--provider-id",
21
+ "eu-west-1_AbCdEfGhI"
22
+ ])).toEqual({
23
+ TAG: "Ok",
24
+ _0: {
25
+ poolName: ProvisionIdentity$ReventlessAws.defaultPoolName,
26
+ providerId: "eu-west-1_AbCdEfGhI",
27
+ help: false
28
+ }
29
+ });
30
+ });
31
+ globalThis.test("--name selects which pool to adopt by name", () => {
32
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
33
+ "--name",
34
+ "examples-dev"
35
+ ])).toEqual({
36
+ TAG: "Ok",
37
+ _0: {
38
+ poolName: "examples-dev",
39
+ providerId: undefined,
40
+ help: false
41
+ }
42
+ });
43
+ });
44
+ globalThis.test("both together are accepted, and the id wins at resolve time", () => {
45
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
46
+ "--name",
47
+ "ignored",
48
+ "--provider-id",
49
+ "eu-west-1_x"
50
+ ])).toEqual({
51
+ TAG: "Ok",
52
+ _0: {
53
+ poolName: "ignored",
54
+ providerId: "eu-west-1_x",
55
+ help: false
56
+ }
57
+ });
58
+ });
59
+ globalThis.test("a misspelled flag is refused, not ignored", () => {
60
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
61
+ "--provider_id",
62
+ "eu-west-1_x"
63
+ ])).toEqual({
64
+ TAG: "Error",
65
+ _0: `unknown argument "--provider_id"`
66
+ });
67
+ });
68
+ globalThis.test("a flag with no value is refused", () => {
69
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs(["--provider-id"])).toEqual({
70
+ TAG: "Error",
71
+ _0: "--provider-id needs a value"
72
+ });
73
+ });
74
+ globalThis.test("--name with no value is refused too", () => {
75
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs(["--name"])).toEqual({
76
+ TAG: "Error",
77
+ _0: "--name needs a value"
78
+ });
79
+ });
80
+ globalThis.test("--help and -h both ask for the usage text", () => {
81
+ globalThis.expect([
82
+ Stdlib_Result.map(ProvisionIdentity$ReventlessAws.parseArgs(["--help"]), a => a.help),
83
+ Stdlib_Result.map(ProvisionIdentity$ReventlessAws.parseArgs(["-h"]), a => a.help)
84
+ ]).toEqual([
85
+ {
86
+ TAG: "Ok",
87
+ _0: true
88
+ },
89
+ {
90
+ TAG: "Ok",
91
+ _0: true
92
+ }
93
+ ]);
94
+ });
95
+ globalThis.test("the first refusal is the one reported", () => {
96
+ globalThis.expect(ProvisionIdentity$ReventlessAws.parseArgs([
97
+ "--nope",
98
+ "--also-nope"
99
+ ])).toEqual({
100
+ TAG: "Error",
101
+ _0: `unknown argument "--nope"`
102
+ });
103
+ });
104
+ });
105
+
106
+ globalThis.describe("ProvisionIdentity.poolSettings", () => {
107
+ let settings = ProvisionIdentity$ReventlessAws.poolSettings("MyIdentity");
108
+ globalThis.test("carries the name it was asked for", () => {
109
+ globalThis.expect(settings.PoolName).toBe("MyIdentity");
110
+ });
111
+ globalThis.test("matches what auto mode declares: email sign-in, no MFA, admin-only", () => {
112
+ globalThis.expect([
113
+ settings.UsernameAttributes,
114
+ settings.MfaConfiguration,
115
+ Stdlib_Option.flatMap(settings.AdminCreateUserConfig, c => c.AllowAdminCreateUserOnly)
116
+ ]).toEqual([
117
+ ["email"],
118
+ "OFF",
119
+ true
120
+ ]);
121
+ });
122
+ globalThis.test("keeps the 12-character password policy", () => {
123
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(settings.Policies, p => p.PasswordPolicy), p => p.MinimumLength)).toEqual(12);
124
+ });
125
+ });
126
+
127
+ let Provision;
128
+
129
+ export {
130
+ Provision,
131
+ }
132
+ /* Not a pure module */
@@ -0,0 +1,53 @@
1
+ // The table half of the index `@owner` derives. Both assertions are about the
2
+ // DynamoDB resource the deploy would submit, which is the only artifact that
3
+ // exists before one.
4
+
5
+ open JestGlobals
6
+
7
+ // `Pulumi.Input.make` is `%identity`, so the provider-facing shape can be read
8
+ // back as the ReScript value it wraps.
9
+ external unwrap: Pulumi.Input.t<'a> => 'a = "%identity"
10
+
11
+ let ownerIndex: Reventless.ReadModel.indexConfig = {
12
+ index: "_owner",
13
+ type_: "S",
14
+ idField: "customerId",
15
+ subIdField: "id",
16
+ projectionType: ALL,
17
+ derived: true,
18
+ }
19
+
20
+ describe("the derived owner index", () => {
21
+ // Not KEYS_ONLY and not INCLUDE: the list door pushes the caller's filter, the
22
+ // retirement predicate and `requireAttribute` down as FilterExpressions over
23
+ // arbitrary columns, and a DynamoDB filter on a GSI may only name projected
24
+ // attributes. A row read through a narrow projection also comes back missing
25
+ // fields, which resolves a non-null SDL field to null.
26
+ testSync("is provisioned projecting every attribute", () => {
27
+ let gsis = QueryDbStorage_DynamoDb.globalSecondaryIndexes([ownerIndex])->unwrap
28
+ expect(
29
+ gsis->Array.map(g => {
30
+ let g = g->unwrap
31
+ (g.name, g.hashKey, g.rangeKey, g.projectionType)
32
+ }),
33
+ )->toEqual([("_owner", "customerId", Some("id"), PulumiAws.DynamoDb.Table.ALL)])
34
+ })
35
+
36
+ // Pulumi rejects a table that defines the same attribute twice, and the derived
37
+ // index sorts on `id` — the table's own partition key — on any view with no
38
+ // `@subId`. Undeduped, adding `@owner` to such a view fails the deploy outright.
39
+ testSync("does not redeclare an attribute the table already has", () => {
40
+ let names = QueryDbStorage_DynamoDb.attributes(None, [ownerIndex])->Array.map(a => a.name)
41
+ expect(names)->toEqual(["id", "customerId"])
42
+ })
43
+
44
+ // The same collision one step out: an index that sorts on the table's sort key.
45
+ testSync("dedupes against the table's own sort key too", () => {
46
+ let names =
47
+ QueryDbStorage_DynamoDb.attributes(
48
+ Some("placedAt"),
49
+ [{...ownerIndex, subIdField: "placedAt"}],
50
+ )->Array.map(a => a.name)
51
+ expect(names)->toEqual(["id", "placedAt", "customerId"])
52
+ })
53
+ })
@@ -0,0 +1,50 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as QueryDbStorage_DynamoDb$ReventlessAws from "../src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
4
+
5
+ let ownerIndex = {
6
+ index: "_owner",
7
+ type_: "S",
8
+ idField: "customerId",
9
+ subIdField: "id",
10
+ projectionType: "ALL",
11
+ derived: true
12
+ };
13
+
14
+ globalThis.describe("the derived owner index", () => {
15
+ globalThis.test("is provisioned projecting every attribute", () => {
16
+ let gsis = QueryDbStorage_DynamoDb$ReventlessAws.globalSecondaryIndexes([ownerIndex]);
17
+ globalThis.expect(gsis.map(g => [
18
+ g.name,
19
+ g.hashKey,
20
+ g.rangeKey,
21
+ g.projectionType
22
+ ])).toEqual([[
23
+ "_owner",
24
+ "customerId",
25
+ "id",
26
+ "ALL"
27
+ ]]);
28
+ });
29
+ globalThis.test("does not redeclare an attribute the table already has", () => {
30
+ let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes(undefined, [ownerIndex]).map(a => a.name);
31
+ globalThis.expect(names).toEqual([
32
+ "id",
33
+ "customerId"
34
+ ]);
35
+ });
36
+ globalThis.test("dedupes against the table's own sort key too", () => {
37
+ let newrecord = {...ownerIndex};
38
+ let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes("placedAt", [(newrecord.subIdField = "placedAt", newrecord)]).map(a => a.name);
39
+ globalThis.expect(names).toEqual([
40
+ "id",
41
+ "placedAt",
42
+ "customerId"
43
+ ]);
44
+ });
45
+ });
46
+
47
+ export {
48
+ ownerIndex,
49
+ }
50
+ /* Not a pure module */
@@ -1,10 +1,8 @@
1
1
  open JestGlobals
2
2
 
3
- // Regression guard for docs/plans/done/platform-plugins-admin-connection-null-rows.md:
4
- // the Plugins admin RM shares its DynamoDB table with internal bookkeeping rows
5
- // (`deploy-schema:*`, `plugin-info:*`, `deploy-schema-hash:*`) that carry no `name`.
6
- // The auto-generated Connection Scan must exclude them, or `name: String!` resolves
7
- // to null and nulls the entire Platform_PluginConnection ("No data" on the page).
3
+ // The Plugins admin RM shares its table with bookkeeping rows (`deploy-schema:*`,
4
+ // `plugin-info:*`, `deploy-schema-hash:*`) carrying no `name`. The Connection Scan
5
+ // must exclude them, or `name: String!` nulls the whole connection.
8
6
 
9
7
  // `Pulumi.Input.make` is `%identity`, so a generated resolver's `Pulumi.Input.t<string>`
10
8
  // IS the underlying JS string — recover it with Obj.magic for assertion.
@@ -39,43 +37,38 @@ describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
39
37
  })
40
38
  })
41
39
 
42
- // Tripwires for docs/plans/done/aws-scan-connection-cursor-roundtrip.md. The Scan
43
- // resolver's cursor must round-trip DynamoDB's own continuation token, not a
44
- // synthetic index every list past page 1 was unreachable before this.
45
- describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
40
+ // Tripwires for the Scan connection's paging. The behaviour itself is exercised
41
+ // against the evaluated resolver in `rescript/pulumi-aws`; these guard the wiring
42
+ // this package is responsible for emitting.
43
+ describe("listAllItemsConnection — paging", () => {
46
44
  let code = AppSync_Resolver_Retrying.Functions.listAllItemsConnection(~labelField="name")->codeOf
47
45
 
48
- testSync("Fix 1: response encodes the real nextToken as the cursor", () => {
49
- expect(code->String.includes("const next = ctx.result?.nextToken ?? null;"))->toBe(true)
50
- expect(
51
- code->String.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))"),
52
- )->toBe(true)
53
- expect(code->String.includes("hasNextPage: !!next,"))->toBe(true)
54
- })
55
-
56
- testSync("Fix 1: request decodes `after` back to the DynamoDB token", () => {
46
+ testSync("the cursor round-trips a read window, not a synthetic index", () => {
57
47
  expect(code->String.includes("JSON.parse(util.base64Decode(ctx.args.after))"))->toBe(true)
58
- expect(code->String.includes("nextToken: after,"))->toBe(true)
59
- })
60
-
61
- testSync("Fix 1: the old synthetic-index cursor is gone", () => {
48
+ expect(code->String.includes("nextToken: _window,"))->toBe(true)
62
49
  // The regression: `cursor: ctx.args.after ? ctx.args.after + '_' + i : '' + i`.
63
50
  expect(code->String.includes("ctx.args.after + '_' + i"))->toBe(false)
64
51
  })
65
52
 
66
- testSync("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
53
+ testSync("a filtered read examines more rows than it serves", () => {
54
+ expect(code->String.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)"))->toBe(true)
55
+ expect(code->String.includes("const _page = _rest.slice(0, _first);"))->toBe(true)
56
+ expect(code->String.includes("hasNextPage: _more || !!_next,"))->toBe(true)
57
+ })
58
+
59
+ testSync("backward paging (last/before) is rejected, not silently mishandled", () => {
67
60
  expect(code->String.includes("ctx.args.before != null || ctx.args.last != null"))->toBe(true)
68
61
  expect(code->String.includes("UnsupportedPagination"))->toBe(true)
69
62
  })
70
63
 
71
- testSync("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
64
+ testSync("a window emptied by the filter still yields a resumable boundary cursor", () => {
72
65
  expect(
73
66
  code->String.includes(
74
- "const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;",
67
+ "const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;",
75
68
  ),
76
69
  )->toBe(true)
77
70
  expect(
78
- code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary"),
71
+ code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary"),
79
72
  )->toBe(true)
80
73
  })
81
74
  })
@@ -19,37 +19,35 @@ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
19
19
 
20
20
  globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
21
21
  globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
22
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined);
22
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined, undefined, undefined);
23
23
  globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
24
24
  globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
25
25
  });
26
26
  globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
27
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
27
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
28
28
  globalThis.expect(code.includes("attribute_exists")).toBe(false);
29
29
  });
30
30
  });
31
31
 
32
- globalThis.describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
33
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
34
- globalThis.test("Fix 1: response encodes the real nextToken as the cursor", () => {
35
- globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
36
- globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);
37
- globalThis.expect(code.includes("hasNextPage: !!next,")).toBe(true);
38
- });
39
- globalThis.test("Fix 1: request decodes `after` back to the DynamoDB token", () => {
32
+ globalThis.describe("listAllItemsConnection — paging", () => {
33
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
34
+ globalThis.test("the cursor round-trips a read window, not a synthetic index", () => {
40
35
  globalThis.expect(code.includes("JSON.parse(util.base64Decode(ctx.args.after))")).toBe(true);
41
- globalThis.expect(code.includes("nextToken: after,")).toBe(true);
42
- });
43
- globalThis.test("Fix 1: the old synthetic-index cursor is gone", () => {
36
+ globalThis.expect(code.includes("nextToken: _window,")).toBe(true);
44
37
  globalThis.expect(code.includes("ctx.args.after + '_' + i")).toBe(false);
45
38
  });
46
- globalThis.test("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
39
+ globalThis.test("a filtered read examines more rows than it serves", () => {
40
+ globalThis.expect(code.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)")).toBe(true);
41
+ globalThis.expect(code.includes("const _page = _rest.slice(0, _first);")).toBe(true);
42
+ globalThis.expect(code.includes("hasNextPage: _more || !!_next,")).toBe(true);
43
+ });
44
+ globalThis.test("backward paging (last/before) is rejected, not silently mishandled", () => {
47
45
  globalThis.expect(code.includes("ctx.args.before != null || ctx.args.last != null")).toBe(true);
48
46
  globalThis.expect(code.includes("UnsupportedPagination")).toBe(true);
49
47
  });
50
- globalThis.test("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
51
- globalThis.expect(code.includes("const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;")).toBe(true);
52
- globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary")).toBe(true);
48
+ globalThis.test("a window emptied by the filter still yields a resumable boundary cursor", () => {
49
+ globalThis.expect(code.includes("const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;")).toBe(true);
50
+ globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary")).toBe(true);
53
51
  });
54
52
  });
55
53