Skip to content

Commit 7da965e

Browse files
authored
Cli/gists_v3_better_cache (#442)
* refactor: Simplify GistCacheEntry structure and enhance company-specific cache handling * feat: Improve multi-tenant handling with support for multiple environments and distinct caches
1 parent e5a3f7f commit 7da965e

File tree

4 files changed

+112
-70
lines changed

4 files changed

+112
-70
lines changed

.changeset/slow-banks-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect-app/cli": patch
3+
---
4+
5+
better multi-tenant handling (multi env, different caches)

packages/cli/src/gist.ts

Lines changed: 86 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,12 @@ export class GistYAML extends Schema.Class<GistYAML>("GistYAML")({
114114
}) {}
115115

116116
/**
117-
* Cache entry representing a gist mapping with company association.
118-
* Each entry contains the gist's human-readable name, GitHub ID, and company context.
119-
* Company field enables multi-tenant cache management where different companies
120-
* can maintain separate gist namespaces within the same cache.
117+
* Cache entry representing a gist mapping.
118+
* Each entry contains the gist's human-readable name and GitHub ID.
121119
*/
122120
export class GistCacheEntry extends Schema.Class<GistCacheEntry>("GistCacheEntry")({
123121
name: Schema.String,
124-
id: Schema.String,
125-
company: Schema.String
122+
id: Schema.String
126123
}) {}
127124

128125
export const GistCacheEntries = Schema.Array(GistCacheEntry)
@@ -146,9 +143,12 @@ export class GistCache {
146143
entries: GistCacheEntries
147144
gist_id: string
148145

149-
constructor({ entries, gist_id }: { entries: GistCacheEntries; gist_id: string }) {
146+
company: string
147+
148+
constructor({ company, entries, gist_id }: { entries: GistCacheEntries; gist_id: string; company: string }) {
150149
this.entries = entries
151150
this.gist_id = gist_id
151+
this.company = company
152152
}
153153
}
154154

@@ -160,6 +160,11 @@ class GistCacheNotFound extends Data.TaggedError("GistCacheNotFound")<{
160160
readonly message: string
161161
}> {}
162162

163+
class GistCacheOfCompanyNotFound extends Data.TaggedError("GistCacheOfCompanyNotFound")<{
164+
readonly message: string
165+
readonly cache_gist_id: string
166+
}> {}
167+
163168
class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
164169
readonly message: string
165170
}> {}
@@ -169,6 +174,7 @@ class GistYAMLError extends Data.TaggedError("GistYAMLError")<{
169174
// Services
170175
//
171176

177+
// @effect-diagnostics-next-line missingEffectServiceDependency:off
172178
class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
173179
dependencies: [RunCommandService.Default],
174180
effect: Effect.gen(function*() {
@@ -200,9 +206,15 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
200206
return gist_id && gist_id.length > 0 ? Option.some(gist_id) : Option.none()
201207
}
202208

203-
const loadGistCache = Effect
209+
const loadGistCache: (
210+
company: string,
211+
rec?: { recCache?: boolean; recCacheCompany?: boolean }
212+
) => Effect.Effect<GistCache, GistCacheOfCompanyNotFound, never> = Effect
204213
.fn("effa-cli.gist.loadGistCache")(
205-
function*() {
214+
function*(
215+
company: string,
216+
{ recCache = false, recCacheCompany = false } = { recCache: false, recCacheCompany: false }
217+
) {
206218
// search for existing cache gist
207219
const output = yield* runGetStringSuppressed(`gh gist list --filter "${CACHE_GIST_DESCRIPTION}"`)
208220
.pipe(Effect.orElse(() => Effect.succeed("")))
@@ -219,52 +231,74 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
219231
const gist_id = parts[0]?.trim()
220232

221233
if (!gist_id) {
234+
if (recCache) {
235+
return yield* Effect.dieMessage("Failed to create or locate cache gist after creation attempt")
236+
}
222237
return yield* new GistCacheNotFound({ message: "No gist ID found in output" })
223238
} else {
224239
yield* Effect.logInfo(`Found existing cache gist with ID ${gist_id}`)
225240
}
226241

227-
// read cache gist content
228-
const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id}`)
229-
.pipe(Effect.orElse(() => Effect.succeed("")))
230-
231-
const entries = yield* pipe(
232-
cacheContent.split(CACHE_GIST_DESCRIPTION)[1]?.trim(),
233-
pipe(Schema.parseJson(GistCacheEntries), Schema.decodeUnknown),
234-
Effect.orElse(() => new GistCacheNotFound({ message: "Failed to parse cache JSON" }))
242+
// read company-specific cache file
243+
const filesInCache = yield* runGetStringSuppressed(`gh gist view ${gist_id} --files`).pipe(
244+
Effect.map((files) =>
245+
files
246+
.trim()
247+
.split("\n")
248+
.map((f) => f.trim())
249+
)
235250
)
236251

237-
return { entries, gist_id }
238-
},
239-
Effect.catchTag("GistCacheNotFound", () =>
240-
Effect.gen(function*() {
241-
// cache doesn't exist, create it
242-
yield* Effect.logInfo("Cache gist not found, creating new cache...")
243-
244-
const cacheJson = yield* pipe(
245-
[],
246-
pipe(Schema.parseJson(GistCacheEntries), Schema.encodeUnknown),
247-
// cannot recover from parse errors in any case, better to die here instead of cluttering the signature
252+
if (!filesInCache.includes(`${company}.json`)) {
253+
if (recCacheCompany) {
254+
return yield* Effect.dieMessage(
255+
`Failed to create or locate cache entry for company ${company} after creation attempt`
256+
)
257+
}
258+
return yield* new GistCacheOfCompanyNotFound({
259+
message: `Cache gist not found of company ${company}`,
260+
cache_gist_id: gist_id
261+
})
262+
} else {
263+
const cacheContent = yield* runGetStringSuppressed(`gh gist view ${gist_id} -f "${company}.json"`)
264+
265+
const entries = yield* pipe(
266+
cacheContent,
267+
pipe(Schema.parseJson(GistCacheEntries), Schema.decodeUnknown),
248268
Effect.orDie
249269
)
250270

251-
const gistUrl = yield* runGetStringSuppressed(
252-
`echo '${cacheJson}' | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -`
253-
)
271+
return new GistCache({ entries, gist_id, company })
272+
}
273+
},
274+
(_, company) =>
275+
_.pipe(
276+
Effect.catchTag("GistCacheNotFound", () =>
277+
Effect.gen(function*() {
278+
yield* Effect.logInfo("Cache gist not found, creating new cache...")
279+
280+
yield* runGetStringSuppressed(
281+
`echo "do_not_delete" | gh gist create --desc="${CACHE_GIST_DESCRIPTION}" -f effa-gist.cache -`
282+
)
254283

255-
const gist_id = yield* pipe(
256-
gistUrl,
257-
extractGistIdFromUrl,
258-
Option.match({
259-
onNone: () => Effect.dieMessage(`Could not extract cache's gist ID from URL: ${gistUrl}`),
260-
onSome: (id) =>
261-
Effect.succeed(id).pipe(Effect.tap(Effect.logInfo(`Created new cache gist with ID ${id}`)))
262-
})
263-
)
284+
// retry loading the cache after creating it
285+
return yield* loadGistCache(company, { recCache: true })
286+
}))
287+
),
288+
(_, company) =>
289+
_.pipe(
290+
Effect.catchTag("GistCacheOfCompanyNotFound", (e) =>
291+
Effect.gen(function*() {
292+
yield* Effect.logInfo(`Cache for company ${company} not found, creating company-specific cache file...`)
293+
294+
yield* runGetStringSuppressed(
295+
`echo "[]" | gh gist edit ${e.cache_gist_id} -a ${company}.json -`
296+
)
264297

265-
return { entries: [], gist_id }
266-
})),
267-
Effect.map(({ entries, gist_id }) => new GistCache({ entries, gist_id }))
298+
// retry loading the cache after creating it
299+
return yield* loadGistCache(company, { recCacheCompany: true })
300+
}))
301+
)
268302
)
269303

270304
const saveGistCache = Effect.fn("effa-cli.gist.saveGistCache")(
@@ -276,7 +310,9 @@ class GHGistService extends Effect.Service<GHGistService>()("GHGistService", {
276310
Effect.orDie
277311
)
278312

279-
yield* runGetExitCodeSuppressed(`echo '${cacheJson}' | gh gist edit ${cache.gist_id} -`)
313+
yield* runGetExitCodeSuppressed(
314+
`echo '${cacheJson}' | gh gist edit ${cache.gist_id} -f ${cache.company}.json -`
315+
)
280316
}
281317
)
282318

@@ -619,7 +655,7 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
619655

620656
yield* GH.login(Redacted.value(redactedToken))
621657

622-
const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache())
658+
const cache = yield* SynchronizedRef.make<GistCache>(yield* GH.loadGistCache(CONFIG.company))
623659

624660
// filter YAML gists by company to ensure isolation between different organizations
625661
// this prevents cross-company gist operations and maintains data separation
@@ -724,10 +760,10 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
724760

725761
files: filesOnDiskWithFullPath,
726762
env: CONFIG.env
727-
})),
728-
company: CONFIG.company
763+
}))
729764
}
730-
]
765+
],
766+
company: cache.company
731767
})
732768
})
733769
)
@@ -753,14 +789,14 @@ export class GistHandler extends Effect.Service<GistHandler>()("GistHandler", {
753789
// this ensures gists from other companies remain untouched
754790
for (let i = newEntries.length - 1; i >= 0; i--) {
755791
const cacheEntry = newEntries[i]
756-
if (cacheEntry && cacheEntry.company === CONFIG.company && !configGistNames.has(cacheEntry.name)) {
792+
if (cacheEntry && !configGistNames.has(cacheEntry.name)) {
757793
// delete the actual gist from GitHub
758794
yield* GH.deleteGist({
759795
gist_id: cacheEntry.id,
760796
gist_name: cacheEntry.name
761797
})
762798
yield* Effect.logInfo(
763-
`Obsolete gist ${cacheEntry.name} of company ${cacheEntry.company} with ID ${cacheEntry.id}) will be removed from cache`
799+
`Obsolete gist ${cacheEntry.name} of company ${CONFIG.company} with ID ${cacheEntry.id}) will be removed from cache`
764800
)
765801
newEntries.splice(i, 1)
766802
}

packages/cli/test.gists.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
gists:
2-
"cli-config-banana":
3-
company: "banana" # acts like a namespace
4-
description: "Package configuration and main source for Effect-App CLI"
5-
public: false
6-
files:
7-
- "package.json"
8-
- "packages/cli/src/index.ts"
2+
# "cli-config-banana":
3+
# company: "banana" # acts like a namespace
4+
# description: "Package configuration and main source for Effect-App CLI"
5+
# public: false
6+
# files:
7+
# - "package.json"
8+
# # - "packages/cli/src/index.ts"
99

1010
"cli-config-mela":
1111
company: "mela" # acts like a namespace

wiki/effect‐app‐cli.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Effect-App CLI (`pnpm effa`)
22

3-
A modern, type-safe CLI for managing Effect-App projects, built with **Effect-TS** for maximum reliability and composability .
3+
A modern, type-safe CLI for managing Effect-App projects, built with **Effect-TS** for maximum reliability and composability.
44

55
## Installation
66

@@ -61,7 +61,7 @@ pnpm effa up
6161
- `@effect-atom/*` - Effect Atom packages
6262
- `effect-app` - Core Effect-App package
6363
- `@effect-app/*` - All Effect-App packages
64-
- Plus any packages listed in `.ncurc.json` reject configuration
64+
- Any packages listed in `.ncurc.json` reject configuration
6565

6666
**What it does:**
6767
1. Reads existing `.ncurc.json` to preserve configured reject patterns
@@ -110,12 +110,12 @@ pnpm effa index-multi
110110

111111
**What it monitors:**
112112
- Directory: `./api/src`
113-
- Files with `.controllers.` pattern
114-
- Files `controllers.ts` and `routes.ts`
113+
- Files with `.controllers.` pattern in the name
114+
- Files named `controllers.ts` and `routes.ts`
115115

116116
**What it does:**
117-
1. **Controller monitoring**: when a `.controllers.` file changes, searches for `controllers.ts` or `routes.ts` in parent directories and fixes them with eslint
118-
2. **Root monitoring**: when any file changes, fixes `index.ts` in the root directory
117+
1. **Controller monitoring**: When a `.controllers.` file changes, searches for `controllers.ts` or `routes.ts` in parent directories and fixes them with eslint
118+
2. **Root monitoring**: When any file changes, fixes `index.ts` in the root directory
119119

120120
### `effa packagejson` - Export Mappings Root
121121

@@ -140,8 +140,8 @@ pnpm effa packagejson-packages
140140

141141
**What it does:**
142142
1. Scans `packages/` directory
143-
2. Finds all packages with `package.json` and `src/`
144-
3. Excludes: `*eslint-codegen-model`, `*vue-components`
143+
2. Finds all packages with `package.json` and `src/` directories
144+
3. Excludes: packages ending with `eslint-codegen-model`, `eslint-shared-config`, and `vue-components`
145145
4. Generates exports for all found packages
146146
5. Monitors each package for changes
147147

@@ -173,7 +173,7 @@ pnpm effa nuke
173173
pnpm effa nuke --store-prune
174174
```
175175

176-
**⚠️ Warning:** This command permanently deletes files and directories. Use `--dry-run` first to preview the cleanup.
176+
**⚠️ Warning:** This command permanently deletes files and directories. Use `--dry-run` first to preview what will be deleted.
177177

178178
### `effa gist` - GitHub Gist Management
179179

@@ -236,7 +236,7 @@ gists:
236236
1. **Multi-Tenant Isolation**:
237237
- Only processes gists matching the current `COMPANY` environment variable
238238
- Different companies can share the same YAML config without interference
239-
- Cache operations are isolated by company context
239+
- Cache operations are isolated by company context using separate JSON files per company
240240
2. **Multi-Environment Support**:
241241
- Files are prefixed with `ENV` name (e.g., `production.package.json`)
242242
- Multiple environments can coexist in the same gist
@@ -253,8 +253,9 @@ gists:
253253
- Handles file name collisions (GitHub gists have flat structure)
254254
5. **GitHub Integration**:
255255
- Supports both public and private gists
256-
- Persistent cache stored as a secret GitHub gist
256+
- Persistent cache stored as a secret GitHub gist with company-specific files (e.g., `company1.json`, `company2.json`)
257257
- Automatic gist deletion when removed from configuration
258+
- Robust error handling for cache creation and company-specific cache files
258259

259260
**Example File Structure in Gists:**
260261
When `ENV=production`, files are automatically renamed with environment prefixes:
@@ -268,7 +269,7 @@ This allows multiple environments to coexist in the same gist without conflicts.
268269
- GitHub CLI (`gh`) installed and configured
269270
- GitHub Personal Access Token with gist scope
270271
- YAML configuration file with proper structure
271-
- `COMPANY` environment variable set for multi-tenant operations
272+
- `COMPANY` environment variable set for multi-tenant operations (each company gets its own cache file)
272273

273274
## Wrap Functionality
274275

@@ -309,7 +310,7 @@ If you tried to use `--wrap "tsc --build ./tsconfig.all.json"` instead, the `--w
309310
1. The CLI command starts and performs its main functionality (monitoring, etc.)
310311
2. **After** the main command is running, it spawns the wrap command as a child process
311312
3. The child process lifecycle is tied to the CLI command - when you stop the CLI (Ctrl+C), the child process is also terminated
312-
4. **Argument-based wrapping takes priority** - if you provide both arguments and the `--wrap` option, the arguments are used
313+
4. **Argument-based wrapping takes priority** - if you provide both arguments and the `--wrap` option, the arguments take precedence
313314

314315
**Key design principle:** The monitoring lifetime is scoped to the child command's lifetime:
315316
- If the wrapped command is one-shot (exits immediately), the monitoring runs once and stops

0 commit comments

Comments
 (0)