@homeflare/alchemy 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +20 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  13. package/dist/github/index.js +21 -2
  14. package/dist/github/index.js.map +6 -5
  15. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  16. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  17. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  18. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  19. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  20. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  21. package/dist/github/repo-policy.d.ts.map +1 -1
  22. package/dist/google-workspace/credentials.d.ts +54 -0
  23. package/dist/google-workspace/credentials.d.ts.map +1 -0
  24. package/dist/google-workspace/domain-alias.d.ts +74 -0
  25. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  26. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  27. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  28. package/dist/google-workspace/group-member.d.ts +73 -0
  29. package/dist/google-workspace/group-member.d.ts.map +1 -0
  30. package/dist/google-workspace/group.d.ts +87 -0
  31. package/dist/google-workspace/group.d.ts.map +1 -0
  32. package/dist/google-workspace/index.d.ts +19 -0
  33. package/dist/google-workspace/index.d.ts.map +1 -0
  34. package/dist/google-workspace/index.js +283 -0
  35. package/dist/google-workspace/index.js.map +17 -0
  36. package/dist/google-workspace/org-unit.d.ts +75 -0
  37. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  38. package/dist/google-workspace/providers.d.ts +16 -0
  39. package/dist/google-workspace/providers.d.ts.map +1 -0
  40. package/dist/google-workspace/resource.d.ts +80 -0
  41. package/dist/google-workspace/resource.d.ts.map +1 -0
  42. package/dist/google-workspace/values.d.ts +28 -0
  43. package/dist/google-workspace/values.d.ts.map +1 -0
  44. package/dist/grafana/credentials.d.ts +38 -0
  45. package/dist/grafana/credentials.d.ts.map +1 -0
  46. package/dist/grafana/datasource.d.ts +90 -0
  47. package/dist/grafana/datasource.d.ts.map +1 -0
  48. package/dist/grafana/fake-grafana.d.ts +48 -0
  49. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  50. package/dist/grafana/index.d.ts +17 -0
  51. package/dist/grafana/index.d.ts.map +1 -0
  52. package/dist/grafana/index.js +149 -0
  53. package/dist/grafana/index.js.map +13 -0
  54. package/dist/grafana/providers.d.ts +17 -0
  55. package/dist/grafana/providers.d.ts.map +1 -0
  56. package/dist/grafana/resource.d.ts +68 -0
  57. package/dist/grafana/resource.d.ts.map +1 -0
  58. package/docs/caddy-fmt.md +59 -0
  59. package/docs/caddy.md +7 -0
  60. package/docs/credentials.md +11 -0
  61. package/docs/google-workspace.md +105 -0
  62. package/docs/grafana.md +102 -0
  63. package/docs/peers.md +9 -0
  64. package/docs/upstream-conformance.md +37 -0
  65. package/package.json +9 -1
  66. package/src/caddy/config-format-warning.test.ts +91 -0
  67. package/src/caddy/config-reconcile.ts +6 -0
  68. package/src/caddy/config.ts +8 -1
  69. package/src/caddy/format-warnings.test.ts +50 -0
  70. package/src/caddy/format-warnings.ts +39 -0
  71. package/src/caddy/format.test.ts +105 -0
  72. package/src/caddy/format.ts +121 -0
  73. package/src/caddy/index.ts +7 -0
  74. package/src/caddy/providers.test.ts +8 -0
  75. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  76. package/src/github/declare-repo-baseline.ts +34 -1
  77. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  78. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  79. package/src/github/repo-auto-merge-gate.ts +57 -0
  80. package/src/github/repo-policy-apply-order.test.ts +72 -0
  81. package/src/github/repo-policy.ts +28 -1
  82. package/src/google-workspace/credentials.test.ts +40 -0
  83. package/src/google-workspace/credentials.ts +75 -0
  84. package/src/google-workspace/domain-alias.test.ts +69 -0
  85. package/src/google-workspace/domain-alias.ts +116 -0
  86. package/src/google-workspace/fake-google-workspace.ts +54 -0
  87. package/src/google-workspace/group-member.test.ts +91 -0
  88. package/src/google-workspace/group-member.ts +126 -0
  89. package/src/google-workspace/group.test.ts +93 -0
  90. package/src/google-workspace/group.ts +128 -0
  91. package/src/google-workspace/index.ts +54 -0
  92. package/src/google-workspace/org-unit.test.ts +81 -0
  93. package/src/google-workspace/org-unit.ts +143 -0
  94. package/src/google-workspace/providers.ts +26 -0
  95. package/src/google-workspace/resource.test.ts +186 -0
  96. package/src/google-workspace/resource.ts +162 -0
  97. package/src/google-workspace/values.ts +36 -0
  98. package/src/grafana/credentials.ts +65 -0
  99. package/src/grafana/datasource.test.ts +183 -0
  100. package/src/grafana/datasource.ts +171 -0
  101. package/src/grafana/fake-grafana.ts +64 -0
  102. package/src/grafana/index.ts +22 -0
  103. package/src/grafana/providers.ts +19 -0
  104. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `GoogleWorkspace.OrgUnit`'s `spec` against a fake Directory API — proves the leading-slash
3
+ * asymmetry `values.ts`'s `stripLeadingSlash`/`withLeadingSlash` exist for (org-unit.ts's note).
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import * as Effect from 'effect/Effect';
7
+ import {
8
+ fakeFailure,
9
+ fakeGoogleWorkspace,
10
+ fakeGoogleWorkspaceLayer,
11
+ } from './fake-google-workspace.ts';
12
+ import { spec } from './org-unit.ts';
13
+
14
+ const UNIT_PATH = '/admin/directory/v1/customer/my_customer/orgunits/Contractors/2026';
15
+ const props = {
16
+ orgUnitPath: '/Contractors/2026',
17
+ parentOrgUnitPath: '/Contractors',
18
+ description: '2026 cohort',
19
+ };
20
+
21
+ const readThrough = (fetchFn: typeof globalThis.fetch) =>
22
+ Effect.runPromise(
23
+ spec.fetchLive(props).pipe(
24
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))),
25
+ Effect.provide(fakeGoogleWorkspaceLayer(fetchFn)),
26
+ ),
27
+ );
28
+
29
+ describe('GoogleWorkspace.OrgUnit spec.fetchLive + spec.attributes', () => {
30
+ test('the path is sent without its leading slash, and read back with one', async () => {
31
+ const fake = fakeGoogleWorkspace((method, url) =>
32
+ method === 'GET' && url.pathname === UNIT_PATH
33
+ ? Response.json({
34
+ description: '2026 cohort',
35
+ name: '2026',
36
+ orgUnitId: 'id:0123',
37
+ orgUnitPath: '/Contractors/2026',
38
+ parentOrgUnitPath: '/Contractors',
39
+ })
40
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
41
+ );
42
+ expect(await readThrough(fake.fetch)).toEqual({
43
+ customerId: 'my_customer',
44
+ description: '2026 cohort',
45
+ name: '2026',
46
+ orgUnitId: 'id:0123',
47
+ orgUnitPath: '/Contractors/2026',
48
+ parentOrgUnitPath: '/Contractors',
49
+ });
50
+ expect(fake.seen).toEqual([{ method: 'GET', path: UNIT_PATH }]);
51
+ });
52
+
53
+ test('a 404 folds to undefined', async () => {
54
+ const fake = fakeGoogleWorkspace(() => fakeFailure(404, 'not found', 'NOT_FOUND'));
55
+ expect(await readThrough(fake.fetch)).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe('GoogleWorkspace.OrgUnit spec.matches', () => {
60
+ const live = {
61
+ customerId: 'my_customer',
62
+ description: '2026 cohort',
63
+ name: '2026',
64
+ orgUnitId: 'id:0123',
65
+ orgUnitPath: '/Contractors/2026',
66
+ parentOrgUnitPath: '/Contractors',
67
+ };
68
+
69
+ test('matching name, parent and description is no drift', () => {
70
+ expect(spec.matches(live, props)).toBe(true);
71
+ });
72
+
73
+ test('a moved parent is drift', () => {
74
+ expect(spec.matches(live, { ...props, parentOrgUnitPath: '/Employees' })).toBe(false);
75
+ });
76
+
77
+ test('an undeclared parentOrgUnitPath converges to the root, deterministically', () => {
78
+ const { parentOrgUnitPath: _p, ...rootProps } = props;
79
+ expect(spec.matches({ ...live, parentOrgUnitPath: '/' }, rootProps)).toBe(true);
80
+ });
81
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `GoogleWorkspace.OrgUnit` — one organizational unit (`admin/directory/v1/customer/{id}/orgunits`).
3
+ *
4
+ * ★ `orgUnitPath` IS THE IDENTITY the way `NetboxPrefix.prefix` is. Google's own doc line is
5
+ * copied onto `OrgUnitProps.orgUnitPath` below because it is the one field this resource cannot
6
+ * get wrong: it is DERIVED on read (`parentOrgUnitPath` + `name`) but AUTHORED as a full path on
7
+ * write, and the API's own path-parameter form drops the leading `/` (`GetOrgunitsRequest`'s
8
+ * `orgUnitPath` doc: "the full path … minus the leading `/`"). `values.ts`'s
9
+ * `stripLeadingSlash`/`withLeadingSlash` are the one place that asymmetry is handled.
10
+ *
11
+ * ⛔ THIS RESOURCE DOES NOT REMOVE SUB-UNITS OR MOVE USERS. Deleting an org unit that still has
12
+ * children or members fails server-side (Directory refuses rather than cascading) — nothing
13
+ * here retries around that refusal, matching S11's "observe before deleting", not "force
14
+ * through".
15
+ *
16
+ * ⛔ `defaultRemovalPolicy: 'retain'` — an org unit governs which Workspace services a user can
17
+ * reach (Google's own doc line on `orgUnitPath`); deleting one out from under live users is
18
+ * exactly the kind of hard-to-reverse action this policy exists to require an explicit opt-in
19
+ * for.
20
+ */
21
+ import { Resource } from 'alchemy';
22
+ import * as Provider from 'alchemy/Provider';
23
+ import * as directory from '@distilled.cloud/google-workspace/unstable/admin_directory_v1';
24
+ import * as Effect from 'effect/Effect';
25
+ import type { GoogleWorkspaceRequirements, GoogleWorkspaceSpec } from './resource.ts';
26
+ import { googleWorkspaceHandlers } from './resource.ts';
27
+ import { stripLeadingSlash, text, withLeadingSlash } from './values.ts';
28
+
29
+ export interface OrgUnitProps {
30
+ /** The unique id for the Workspace account, or `my_customer` for the caller's own account. */
31
+ customerId?: string;
32
+ /**
33
+ * The full path to the organizational unit, e.g. `/Contractors/2026`. Required. In order to
34
+ * edit an `orgUnitPath`, either update `name` or `parentOrgUnitPath` — this field is derived on
35
+ * read, not itself patchable, so declaring a different path here plans a `replace`.
36
+ */
37
+ orgUnitPath: string;
38
+ /** The parent's full path, e.g. `/Contractors`. Omit for a top-level unit under `/`. */
39
+ parentOrgUnitPath?: string;
40
+ description?: string;
41
+ }
42
+
43
+ export interface OrgUnitAttributes {
44
+ customerId: string;
45
+ orgUnitPath: string;
46
+ parentOrgUnitPath: string;
47
+ name: string;
48
+ description: string;
49
+ orgUnitId: string;
50
+ }
51
+
52
+ export interface GoogleWorkspaceOrgUnit extends Resource<
53
+ 'GoogleWorkspace.OrgUnit',
54
+ OrgUnitProps,
55
+ OrgUnitAttributes,
56
+ never,
57
+ GoogleWorkspaceRequirements
58
+ > {}
59
+
60
+ export const GoogleWorkspaceOrgUnit = Resource<GoogleWorkspaceOrgUnit>('GoogleWorkspace.OrgUnit', {
61
+ defaultRemovalPolicy: 'retain',
62
+ });
63
+
64
+ const customerOf = (props: OrgUnitProps): string => props.customerId ?? 'my_customer';
65
+ /** The path segment the API wants, split from its own leading-name component for `name`/`insert`. */
66
+ const nameOf = (props: OrgUnitProps): string =>
67
+ props.orgUnitPath.split('/').filter(Boolean).pop() ?? props.orgUnitPath;
68
+ const parentOf = (props: OrgUnitProps): string => props.parentOrgUnitPath ?? '/';
69
+
70
+ const attributesOf = (
71
+ live: directory.OrgUnit,
72
+ props: OrgUnitProps,
73
+ ): OrgUnitAttributes | undefined => {
74
+ const livePath = live.orgUnitPath === undefined ? undefined : withLeadingSlash(live.orgUnitPath);
75
+ if (livePath === undefined || livePath.toLowerCase() !== props.orgUnitPath.toLowerCase()) {
76
+ return undefined;
77
+ }
78
+ return {
79
+ customerId: customerOf(props),
80
+ description: text(live.description),
81
+ name: text(live.name),
82
+ orgUnitId: text(live.orgUnitId),
83
+ orgUnitPath: livePath,
84
+ parentOrgUnitPath: text(live.parentOrgUnitPath),
85
+ };
86
+ };
87
+
88
+ /** ★ EXPORTED for direct testing — see org-unit.test.ts. */
89
+ export const spec: GoogleWorkspaceSpec<
90
+ OrgUnitProps,
91
+ directory.OrgUnit,
92
+ OrgUnitAttributes,
93
+ | directory.GetOrgunitsError
94
+ | directory.InsertOrgunitsError
95
+ | directory.PatchOrgunitsError
96
+ | directory.DeleteOrgunitsError
97
+ > = {
98
+ attributes: attributesOf,
99
+ create: (props) =>
100
+ directory.insertOrgunits({
101
+ body: {
102
+ description: props.description ?? '',
103
+ name: nameOf(props),
104
+ parentOrgUnitPath: parentOf(props),
105
+ },
106
+ customerId: customerOf(props),
107
+ }),
108
+ describe: (props) => `customer/${customerOf(props)}/orgunits${props.orgUnitPath}`,
109
+ destroy: (props, live) =>
110
+ directory.deleteOrgunits({
111
+ customerId: customerOf(props),
112
+ orgUnitPath: stripLeadingSlash(text(live.orgUnitPath) || props.orgUnitPath),
113
+ }),
114
+ fetchLive: (props) =>
115
+ directory
116
+ .getOrgunits({
117
+ customerId: customerOf(props),
118
+ orgUnitPath: stripLeadingSlash(props.orgUnitPath),
119
+ })
120
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
121
+ matches: (attributes, props) =>
122
+ attributes.description === (props.description ?? '') &&
123
+ attributes.parentOrgUnitPath === parentOf(props) &&
124
+ attributes.name === nameOf(props),
125
+ update: (props, live) =>
126
+ directory.patchOrgunits({
127
+ body: {
128
+ description: props.description ?? '',
129
+ name: nameOf(props),
130
+ parentOrgUnitPath: parentOf(props),
131
+ },
132
+ customerId: customerOf(props),
133
+ orgUnitPath: stripLeadingSlash(text(live.orgUnitPath) || props.orgUnitPath),
134
+ }),
135
+ };
136
+
137
+ export const handlers = googleWorkspaceHandlers(spec);
138
+
139
+ export const GoogleWorkspaceOrgUnitProvider = () =>
140
+ Provider.effect(
141
+ GoogleWorkspaceOrgUnit,
142
+ Effect.succeed(GoogleWorkspaceOrgUnit.Provider.of(handlers)),
143
+ );
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Every Google Workspace provider, wired once — the same role `litellmProviders`/`netboxProviders`-
3
+ * style helpers play elsewhere in this kit (S16: "a subpath's `providers()` plays the same role" as
4
+ * upstream's `Providers.ts`).
5
+ *
6
+ * Layer.mergeAll(googleWorkspaceProviders(), …the stack's other providers)
7
+ *
8
+ * ⚠️ CREDENTIALS ARE NOT A PARAMETER HERE. Every handler already closes over
9
+ * `CredentialsFromEnv` inside `resource.ts` (`GOOGLE_ACCESS_TOKEN`, resolved at call time — see
10
+ * credentials.ts), so this layer only wires the four resource providers together. `HttpClient`
11
+ * is NOT provided here either — it is ambient in every Alchemy runtime, the same note
12
+ * `litellm/providers.ts` makes for its own family.
13
+ */
14
+ import * as Layer from 'effect/Layer';
15
+ import { GoogleWorkspaceDomainAliasProvider } from './domain-alias.ts';
16
+ import { GoogleWorkspaceGroupProvider } from './group.ts';
17
+ import { GoogleWorkspaceGroupMemberProvider } from './group-member.ts';
18
+ import { GoogleWorkspaceOrgUnitProvider } from './org-unit.ts';
19
+
20
+ export const googleWorkspaceProviders = () =>
21
+ Layer.mergeAll(
22
+ GoogleWorkspaceGroupProvider(),
23
+ GoogleWorkspaceGroupMemberProvider(),
24
+ GoogleWorkspaceDomainAliasProvider(),
25
+ GoogleWorkspaceOrgUnitProvider(),
26
+ );
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `googleWorkspaceOperations`'s shared `diff`/`reconcile` engine, exercised end-to-end through a
3
+ * fake Directory API — not just through a resource's own `spec.matches`/`fetchLive` in isolation.
4
+ *
5
+ * 🔴 WHY THIS FILE EXISTS. An adversarial review of this family (2026-09-24) found that every
6
+ * other test here calls `spec.fetchLive`/`spec.attributes`/`spec.matches` directly and never
7
+ * `googleWorkspaceOperations(spec).diff`/`.reconcile` — so a bug INSIDE `resource.ts` itself
8
+ * (shared by all four resources) could invert `diff`'s `noop`/`update` branches, or skip the
9
+ * "write returned no error but the object is still absent" die-guard, and no test here would
10
+ * catch it. This file closes that gap: it drives the real engine against real specs (`group.ts`,
11
+ * whose spec HAS `update`, and `domain-alias.ts`, whose spec does NOT), through the same fake
12
+ * HTTP layer every other test in this family uses.
13
+ */
14
+ import { describe, expect, test } from 'bun:test';
15
+ import * as Effect from 'effect/Effect';
16
+ import { spec as aliasSpec } from './domain-alias.ts';
17
+ import {
18
+ fakeFailure,
19
+ fakeGoogleWorkspace,
20
+ fakeGoogleWorkspaceLayer,
21
+ } from './fake-google-workspace.ts';
22
+ import { spec as groupSpec } from './group.ts';
23
+ import { googleWorkspaceOperations } from './resource.ts';
24
+
25
+ const groupOps = googleWorkspaceOperations(groupSpec);
26
+ const groupProps = { email: 'ops@schenanigans.com', name: 'Ops', description: 'ops team' };
27
+ const GROUP_PATH = '/admin/directory/v1/groups/ops%40schenanigans.com';
28
+
29
+ describe('googleWorkspaceOperations(group spec).reconcile', () => {
30
+ test('absent → create, then read back — the happy create path', async () => {
31
+ let created = false;
32
+ const fake = fakeGoogleWorkspace((method, url) => {
33
+ if (method === 'POST' && url.pathname === '/admin/directory/v1/groups') {
34
+ created = true;
35
+ return Response.json({ email: groupProps.email, name: 'Ops' });
36
+ }
37
+ if (method === 'GET' && url.pathname === GROUP_PATH) {
38
+ return created
39
+ ? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
40
+ : fakeFailure(404, 'not found', 'NOT_FOUND');
41
+ }
42
+ return fakeFailure(404, 'not found', 'NOT_FOUND');
43
+ });
44
+ const attrs = await Effect.runPromise(
45
+ groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
46
+ );
47
+ expect(created).toBe(true);
48
+ expect(attrs.email).toBe(groupProps.email);
49
+ });
50
+
51
+ test('present and matching → no PATCH is ever sent', async () => {
52
+ const fake = fakeGoogleWorkspace((method, url) =>
53
+ method === 'GET' && url.pathname === GROUP_PATH
54
+ ? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
55
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
56
+ );
57
+ await Effect.runPromise(
58
+ groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
59
+ );
60
+ expect(fake.seen.some((r) => r.method === 'PATCH')).toBe(false);
61
+ });
62
+
63
+ test('present and drifted → PATCH is sent exactly once', async () => {
64
+ let patched = false;
65
+ const fake = fakeGoogleWorkspace((method, url) => {
66
+ if (method === 'PATCH' && url.pathname === GROUP_PATH) {
67
+ patched = true;
68
+ return Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' });
69
+ }
70
+ if (method === 'GET' && url.pathname === GROUP_PATH) {
71
+ return Response.json({
72
+ description: 'stale description',
73
+ email: groupProps.email,
74
+ name: 'Ops',
75
+ });
76
+ }
77
+ return fakeFailure(404, 'not found', 'NOT_FOUND');
78
+ });
79
+ await Effect.runPromise(
80
+ groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
81
+ );
82
+ expect(patched).toBe(true);
83
+ });
84
+
85
+ test('a write that reports success but leaves the object absent dies — never lies to the plan', async () => {
86
+ const fake = fakeGoogleWorkspace(
87
+ (method, url) =>
88
+ method === 'POST' && url.pathname === '/admin/directory/v1/groups'
89
+ ? Response.json({})
90
+ : fakeFailure(404, 'not found', 'NOT_FOUND'), // every GET stays 404, even after "create"
91
+ );
92
+ const failed = await Effect.runPromiseExit(
93
+ groupOps.reconcile(groupProps).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
94
+ );
95
+ expect(failed._tag).toBe('Failure');
96
+ });
97
+ });
98
+
99
+ describe('googleWorkspaceOperations(group spec).diff', () => {
100
+ const live = {
101
+ adminCreated: false,
102
+ aliases: [],
103
+ description: 'ops team',
104
+ directMembersCount: '0',
105
+ email: groupProps.email,
106
+ groupId: 'x',
107
+ name: 'Ops',
108
+ nonEditableAliases: [],
109
+ };
110
+
111
+ test('output undefined → undefined (engine default: treat as update)', async () => {
112
+ // ⚠️ `diff` short-circuits on `output === undefined` before it ever reads — no request is
113
+ // made — but the Effect's TYPE still carries `GoogleWorkspaceOpContext`, so a layer is
114
+ // still required to satisfy `runPromise`. `fake.seen` below proves the short-circuit.
115
+ const fake = fakeGoogleWorkspace(() => fakeFailure(500, 'must not be called', 'INTERNAL'));
116
+ const result = await Effect.runPromise(
117
+ groupOps
118
+ .diff(groupProps, undefined)
119
+ .pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
120
+ );
121
+ expect(result).toBeUndefined();
122
+ expect(fake.seen).toEqual([]);
123
+ });
124
+
125
+ test('matching live → noop, read through the real fake', async () => {
126
+ const fake = fakeGoogleWorkspace((method, url) =>
127
+ method === 'GET' && url.pathname === GROUP_PATH
128
+ ? Response.json({ description: 'ops team', email: groupProps.email, name: 'Ops' })
129
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
130
+ );
131
+ const result = await Effect.runPromise(
132
+ groupOps.diff(groupProps, live).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
133
+ );
134
+ expect(result).toEqual({ action: 'noop' });
135
+ });
136
+
137
+ test('drifted live, spec HAS update → action is update, not replace', async () => {
138
+ const fake = fakeGoogleWorkspace((method, url) =>
139
+ method === 'GET' && url.pathname === GROUP_PATH
140
+ ? Response.json({
141
+ description: 'a different description',
142
+ email: groupProps.email,
143
+ name: 'Ops',
144
+ })
145
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
146
+ );
147
+ const result = await Effect.runPromise(
148
+ groupOps.diff(groupProps, live).pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
149
+ );
150
+ expect(result).toEqual({ action: 'update' });
151
+ });
152
+ });
153
+
154
+ describe('googleWorkspaceOperations(domain-alias spec).diff — spec has NO update', () => {
155
+ const aliasOps = googleWorkspaceOperations(aliasSpec);
156
+ const aliasProps = {
157
+ domainAliasName: 'alias.schenanigans.com',
158
+ parentDomainName: 'schenanigans.com',
159
+ };
160
+ const ALIAS_PATH =
161
+ '/admin/directory/v1/customer/my_customer/domainaliases/alias.schenanigans.com';
162
+ const liveAttrs = {
163
+ creationTime: '0',
164
+ customer: 'my_customer',
165
+ domainAliasName: aliasProps.domainAliasName,
166
+ parentDomainName: aliasProps.parentDomainName,
167
+ verified: true,
168
+ };
169
+
170
+ test('drifted live, spec has NO update → replace, never update', async () => {
171
+ const fake = fakeGoogleWorkspace((method, url) =>
172
+ method === 'GET' && url.pathname === ALIAS_PATH
173
+ ? Response.json({
174
+ domainAliasName: aliasProps.domainAliasName,
175
+ parentDomainName: 'homeflare.dev',
176
+ })
177
+ : fakeFailure(404, 'not found', 'NOT_FOUND'),
178
+ );
179
+ const result = await Effect.runPromise(
180
+ aliasOps
181
+ .diff(aliasProps, liveAttrs)
182
+ .pipe(Effect.provide(fakeGoogleWorkspaceLayer(fake.fetch))),
183
+ );
184
+ expect(result).toEqual({ action: 'replace' });
185
+ });
186
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * One shape for every Google Workspace Directory object — the same four operations, different
3
+ * distilled call.
4
+ *
5
+ * ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Groups, group members, domain
6
+ * aliases and org units are all create / read / update / delete against
7
+ * `@distilled.cloud/google-workspace`'s typed `admin_directory_v1` operations. Hand-rolling the
8
+ * fourth copy is where someone drops the read-back or the `isResolved` guard and the plan lies.
9
+ *
10
+ * ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly.
11
+ * The Directory API addresses every object this family models by a stable key (an email, a
12
+ * domain alias name, an org unit path), so — unlike NetBox's page-then-disambiguate `locateOne`
13
+ * — `fetchLive` is always a plain get-by-key.
14
+ *
15
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
16
+ * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
17
+ * a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
18
+ * resource file's own call site.
19
+ *
20
+ * ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: a group or org
21
+ * unit the Workspace already has is bound, not duplicated — Tim's own domain (schenanigans.com)
22
+ * predates this code. `spec.attributes` always maps the FULL live object, so a bound row reads
23
+ * back byte-identical to what Workspace actually holds — the exact-as-live half of `adopt(true)`;
24
+ * the other half (refusing to bind without it) is upstream's `AdoptPolicy`, unchanged here.
25
+ *
26
+ * ⛔ CREDENTIALS ARE NEVER A PROP (S24/S25). See `credentials.ts` for what a stack passes instead.
27
+ */
28
+ import { isResolved } from 'alchemy/Diff';
29
+ import type { Input } from 'alchemy/Input';
30
+ import { CredentialsFromEnv } from '@distilled.cloud/google-workspace/Credentials';
31
+ import type { GoogleWorkspaceOpContext } from '@distilled.cloud/google-workspace/Protocol';
32
+ import * as Effect from 'effect/Effect';
33
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
34
+
35
+ /**
36
+ * ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
37
+ * `GoogleWorkspaceOpContext` is `Credentials | HttpClient.HttpClient`; `googleWorkspaceHandlers`
38
+ * closes over `CredentialsFromEnv` itself (`GOOGLE_ACCESS_TOKEN` / `GOOGLE_PROJECT_ID` — see
39
+ * credentials.ts), so a consuming stack only has to provide `HttpClient.HttpClient`, the same
40
+ * shape every other distilled-backed family in this kit asks for.
41
+ */
42
+ export type GoogleWorkspaceRequirements = HttpClient.HttpClient;
43
+
44
+ /**
45
+ * One Directory object's distilled calls. `Live` is whatever the SDK decodes (a `Group`, a
46
+ * `Member`, a `DomainAlias`, an `OrgUnit`) — never an untyped response body.
47
+ *
48
+ * `E` is left to each resource file to declare (the union of the distilled operations it
49
+ * actually calls), so a call site's precise per-operation error union flows straight through
50
+ * instead of being widened by hand here.
51
+ */
52
+ export type GoogleWorkspaceSpec<Props extends object, Live, Attributes, E> = {
53
+ /** Already folds a real "absent" outcome — a `catchTag('NotFound', ...)` at the call site. */
54
+ readonly fetchLive: (
55
+ props: Props,
56
+ ) => Effect.Effect<Live | undefined, E, GoogleWorkspaceOpContext>;
57
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
58
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
59
+ /** Absent for an existence-only family (none here yet, but `GroupMember` could become one). */
60
+ readonly create?: (props: Props) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
61
+ readonly update?: (
62
+ props: Props,
63
+ live: Live,
64
+ ) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
65
+ readonly destroy: (
66
+ props: Props,
67
+ live: Live,
68
+ ) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
69
+ /** For the read-back-failure message. */
70
+ readonly describe: (props: Props) => string;
71
+ };
72
+
73
+ export const googleWorkspaceOperations = <Props extends object, Live, Attributes, E>(
74
+ spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
75
+ ) => {
76
+ const read = (props: Props) =>
77
+ spec
78
+ .fetchLive(props)
79
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
80
+
81
+ return {
82
+ read,
83
+
84
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
85
+ Effect.gen(function* () {
86
+ if (output === undefined || !isResolved(news)) return undefined;
87
+ const live = yield* read(news);
88
+ if (live === undefined) return { action: 'update' } as const;
89
+ if (spec.matches(live, news)) return { action: 'noop' } as const;
90
+ return spec.update === undefined
91
+ ? ({ action: 'replace' } as const)
92
+ : ({ action: 'update' } as const);
93
+ }),
94
+
95
+ reconcile: (news: Props) =>
96
+ Effect.gen(function* () {
97
+ const before = yield* spec.fetchLive(news);
98
+ if (before === undefined) {
99
+ if (spec.create === undefined) {
100
+ return yield* Effect.die(
101
+ new Error(`${spec.describe(news)}: spec declares no create for a missing object`),
102
+ );
103
+ }
104
+ yield* spec.create(news);
105
+ } else if (spec.update !== undefined) {
106
+ const mapped = spec.attributes(before, news);
107
+ // ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
108
+ // a row to PATCH blindly.
109
+ if (mapped !== undefined && !spec.matches(mapped, news)) {
110
+ yield* spec.update(news, before);
111
+ }
112
+ }
113
+ const after = yield* read(news);
114
+ if (after === undefined) {
115
+ return yield* Effect.die(
116
+ new Error(
117
+ `${spec.describe(news)}: the write returned no error but the object is still ` +
118
+ 'absent. Directory returns the object it wrote — read back rather than trusting ' +
119
+ 'the status code.',
120
+ ),
121
+ );
122
+ }
123
+ return after;
124
+ }),
125
+
126
+ destroy: (olds: Props) =>
127
+ Effect.gen(function* () {
128
+ const live = yield* spec.fetchLive(olds);
129
+ if (live === undefined) return;
130
+ yield* spec.destroy(olds, live);
131
+ }),
132
+ };
133
+ };
134
+
135
+ /**
136
+ * The five provider handlers for a spec'd Directory object, wired once.
137
+ *
138
+ * ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
139
+ * `GOOGLE_ACCESS_TOKEN` on the calling fiber per request (credentials.ts's own note), so this
140
+ * closure keeps that laziness — nothing is captured at module load.
141
+ *
142
+ * ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
143
+ *
144
+ * ⚠️ `list` answers empty. The Directory API's list endpoints page the whole domain — every group,
145
+ * every org unit — so adoption stays explicit rather than sweeping (same posture as NetBox and
146
+ * Forgejo; the census handoff script, not this provider, is where a full inventory belongs).
147
+ */
148
+ export const googleWorkspaceHandlers = <Props extends object, Live, Attributes extends object, E>(
149
+ spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
150
+ ) => {
151
+ const ops = googleWorkspaceOperations(spec);
152
+ const withCredentials = <A>(effect: Effect.Effect<A, E, GoogleWorkspaceOpContext>) =>
153
+ Effect.provide(effect, CredentialsFromEnv);
154
+ return {
155
+ list: () => Effect.succeed([]),
156
+ read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
157
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
158
+ withCredentials(ops.diff(news, output)),
159
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
160
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
161
+ };
162
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Wire coercions shared across Google Workspace resources.
3
+ *
4
+ * ⚠️ EVERY FIELD ON A GENERATED DIRECTORY TYPE IS `S.optional(...)` (the SDK does not know which
5
+ * fields Google always sends), so a live read is `string | undefined` even for a field the API
6
+ * guarantees in practice. These helpers turn that into the settled value a `matches()` compares
7
+ * against, mirroring `../netbox/values.ts` and `../forgejo/values.ts` for the same reason: a
8
+ * `diff` that compared `undefined` against a declared default would see drift forever.
9
+ */
10
+
11
+ /** `''` for absent — Directory objects that omit a text field mean "no value", not `undefined`. */
12
+ export const text = (value: string | undefined): string => value ?? '';
13
+
14
+ export const bool = (value: boolean | undefined): boolean => value === true;
15
+
16
+ /**
17
+ * Wire arrays compared in sorted order. `StringList` (group aliases, org unit paths in a search)
18
+ * decodes to `string[] | undefined`; Directory does not promise an order.
19
+ */
20
+ export const stringArray = (value: readonly string[] | undefined): string[] =>
21
+ value === undefined ? [] : [...value].toSorted();
22
+
23
+ /**
24
+ * Every user- and group-facing key in this family is case-insensitive on the wire (Google lower-
25
+ * cases email addresses and org unit path segments internally) but a declaration may be typed in
26
+ * mixed case. Compare through this, never with `===`, or an adopted object drifts on every plan.
27
+ */
28
+ export const ciEqual = (left: string, right: string): boolean =>
29
+ left.toLowerCase() === right.toLowerCase();
30
+
31
+ /** An org unit path as the API wants it for a path segment: no leading `/`. */
32
+ export const stripLeadingSlash = (path: string): string => path.replace(/^\/+/, '');
33
+
34
+ /** An org unit path as attributes should read it back: exactly one leading `/`. */
35
+ export const withLeadingSlash = (path: string): string =>
36
+ path.startsWith('/') ? path : `/${path}`;