lalph 0.1.113 → 0.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lalph",
3
3
  "type": "module",
4
- "version": "0.1.113",
4
+ "version": "0.2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
package/src/GitFlow.ts CHANGED
@@ -7,6 +7,7 @@ import { CurrentWorkerState } from "./Workers.ts"
7
7
  import { Atom } from "effect/unstable/reactivity"
8
8
  import { parseBranch } from "./shared/git.ts"
9
9
  import { AtomRegistry } from "effect/unstable/reactivity"
10
+ import { CurrentProjectId } from "./Settings.ts"
10
11
 
11
12
  // @effect-diagnostics-next-line leakingRequirements:off
12
13
  export class GitFlow extends ServiceMap.Service<
@@ -30,7 +31,7 @@ export class GitFlow extends ServiceMap.Service<
30
31
  }) => Effect.Effect<
31
32
  void,
32
33
  IssueSourceError | PlatformError | GitFlowError,
33
- Prd | IssueSource
34
+ Prd | IssueSource | CurrentProjectId
34
35
  >
35
36
  readonly autoMerge: (options: {
36
37
  readonly targetBranch: string | undefined
@@ -39,7 +40,7 @@ export class GitFlow extends ServiceMap.Service<
39
40
  }) => Effect.Effect<
40
41
  void,
41
42
  IssueSourceError | PlatformError | GitFlowError,
42
- Prd | IssueSource
43
+ Prd | IssueSource | CurrentProjectId
43
44
  >
44
45
  }
45
46
  >()("lalph/GitFlow") {}
@@ -115,7 +116,7 @@ export const GitFlowCommit = Layer.effect(
115
116
 
116
117
  return GitFlow.of({
117
118
  requiresGithubPr: false,
118
- branch: `lalph/worker-${workerState.iteration}`,
119
+ branch: `lalph/worker-${workerState.id}`,
119
120
 
120
121
  setupInstructions: () =>
121
122
  `You are already on a new branch for this task. You do not need to checkout any other branches.`,
@@ -161,12 +162,14 @@ After making any changes, commit them to the same branch. Do not git push your c
161
162
  }),
162
163
  autoMerge: Effect.fnUntraced(function* (options) {
163
164
  const prd = yield* Prd
165
+ const projectId = yield* CurrentProjectId
164
166
  const issue = yield* prd.findById(options.issueId)
165
167
  if (!issue || issue.state !== "in-review") {
166
168
  return
167
169
  }
168
170
  const source = yield* IssueSource
169
171
  yield* source.updateIssue({
172
+ projectId,
170
173
  issueId: options.issueId,
171
174
  state: "done",
172
175
  })
package/src/Github.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Api } from "@octokit/plugin-rest-endpoint-methods"
2
2
  import type { OctokitResponse } from "@octokit/types"
3
3
  import {
4
+ Cache,
4
5
  Data,
5
6
  DateTime,
6
7
  Effect,
@@ -17,11 +18,12 @@ import {
17
18
  import { Octokit } from "octokit"
18
19
  import { IssueSource, IssueSourceError } from "./IssueSource.ts"
19
20
  import { PrdIssue } from "./domain/PrdIssue.ts"
20
- import { Setting } from "./Settings.ts"
21
+ import { CurrentProjectId, ProjectSetting, Settings } from "./Settings.ts"
21
22
  import { Prompt } from "effect/unstable/cli"
22
23
  import { TokenManager } from "./Github/TokenManager.ts"
23
24
  import { GithubCli } from "./Github/Cli.ts"
24
25
  import { Reactivity } from "effect/unstable/reactivity"
26
+ import type { ProjectId } from "./domain/Project.ts"
25
27
 
26
28
  export class GithubError extends Data.TaggedError("GithubError")<{
27
29
  readonly cause: unknown
@@ -100,8 +102,19 @@ export const GithubIssueSource = Layer.effect(
100
102
  Effect.gen(function* () {
101
103
  const github = yield* Github
102
104
  const cli = yield* GithubCli
103
- const labelFilter = yield* getOrSelectLabel
104
- const autoMergeLabelName = yield* getOrSelectAutoMergeLabel
105
+ const projectSettings = yield* Cache.make({
106
+ lookup: Effect.fnUntraced(
107
+ function* (_projectId: ProjectId) {
108
+ const labelFilter = yield* getOrSelectLabel
109
+ const autoMergeLabelName = yield* getOrSelectAutoMergeLabel
110
+ return { labelFilter, autoMergeLabelName } as const
111
+ },
112
+ Effect.orDie,
113
+ (effect, projectId) =>
114
+ Effect.provideService(effect, CurrentProjectId, projectId),
115
+ ),
116
+ capacity: Number.POSITIVE_INFINITY,
117
+ })
105
118
 
106
119
  const hasLabel = (
107
120
  label: ReadonlyArray<
@@ -115,8 +128,8 @@ export const GithubIssueSource = Layer.effect(
115
128
  label.some((l) => (typeof l === "string" ? l === name : l.name === name))
116
129
 
117
130
  const listOpenBlockedBy = (issueId: number) =>
118
- github
119
- .stream((rest, page) =>
131
+ pipe(
132
+ github.stream((rest, page) =>
120
133
  rest.issues.listDependenciesBlockedBy({
121
134
  owner: cli.owner,
122
135
  repo: cli.repo,
@@ -124,38 +137,42 @@ export const GithubIssueSource = Layer.effect(
124
137
  per_page: 100,
125
138
  page,
126
139
  }),
127
- )
128
- .pipe(Stream.filter((issue) => issue.state === "open"))
140
+ ),
141
+ Stream.filter((issue) => issue.state === "open"),
142
+ )
129
143
 
130
- const recentlyClosed = github
131
- .stream((rest, page) =>
144
+ const recentlyClosed = pipe(
145
+ github.stream((rest, page) =>
132
146
  rest.issues.listForRepo({
133
147
  owner: cli.owner,
134
148
  repo: cli.repo,
135
149
  state: "closed",
136
150
  per_page: 100,
137
151
  page,
138
- labels: Option.getOrUndefined(labelFilter),
139
152
  since: DateTime.nowUnsafe().pipe(
140
153
  DateTime.subtract({ days: 3 }),
141
154
  DateTime.formatIso,
142
155
  ),
143
156
  }),
144
- )
145
- .pipe(Stream.filter((issue) => issue.state_reason !== "not_planned"))
157
+ ),
158
+ Stream.filter((issue) => issue.state_reason !== "not_planned"),
159
+ )
146
160
 
147
- const issues = github
148
- .stream((rest, page) =>
149
- rest.issues.listForRepo({
150
- owner: cli.owner,
151
- repo: cli.repo,
152
- state: "open",
153
- per_page: 100,
154
- page,
155
- labels: Option.getOrUndefined(labelFilter),
156
- }),
157
- )
158
- .pipe(
161
+ const issues = (options: {
162
+ readonly labelFilter: Option.Option<string>
163
+ readonly autoMergeLabelName: Option.Option<string>
164
+ }) =>
165
+ pipe(
166
+ github.stream((rest, page) =>
167
+ rest.issues.listForRepo({
168
+ owner: cli.owner,
169
+ repo: cli.repo,
170
+ state: "open",
171
+ per_page: 100,
172
+ page,
173
+ labels: Option.getOrUndefined(options.labelFilter),
174
+ }),
175
+ ),
159
176
  Stream.merge(recentlyClosed),
160
177
  Stream.filter((issue) => issue.pull_request === undefined),
161
178
  Stream.mapEffect(
@@ -179,7 +196,7 @@ export const GithubIssueSource = Layer.effect(
179
196
  estimate: null,
180
197
  state,
181
198
  blockedBy: dependencies.map((dep) => `#${dep.number}`),
182
- autoMerge: autoMergeLabelName.pipe(
199
+ autoMerge: options.autoMergeLabelName.pipe(
183
200
  Option.map((labelName) => hasLabel(issue.labels, labelName)),
184
201
  Option.getOrElse(() => false),
185
202
  ),
@@ -237,9 +254,16 @@ export const GithubIssueSource = Layer.effect(
237
254
  })
238
255
 
239
256
  return yield* IssueSource.make({
240
- issues,
257
+ issues: Effect.fnUntraced(function* (projectId) {
258
+ const settings = yield* Cache.get(projectSettings, projectId)
259
+ return yield* issues(settings)
260
+ }),
241
261
  createIssue: Effect.fnUntraced(
242
- function* (issue: PrdIssue) {
262
+ function* (projectId, issue) {
263
+ const { labelFilter, autoMergeLabelName } = yield* Cache.get(
264
+ projectSettings,
265
+ projectId,
266
+ )
243
267
  const created = yield* createIssue({
244
268
  owner: cli.owner,
245
269
  repo: cli.repo,
@@ -282,6 +306,10 @@ export const GithubIssueSource = Layer.effect(
282
306
  ),
283
307
  updateIssue: Effect.fnUntraced(
284
308
  function* (options) {
309
+ const { labelFilter, autoMergeLabelName } = yield* Cache.get(
310
+ projectSettings,
311
+ options.projectId,
312
+ )
285
313
  const issueNumber = Number(options.issueId.slice(1))
286
314
  const update: {
287
315
  owner: string
@@ -372,7 +400,7 @@ export const GithubIssueSource = Layer.effect(
372
400
  Effect.mapError((cause) => new IssueSourceError({ cause })),
373
401
  ),
374
402
  cancelIssue: Effect.fnUntraced(
375
- function* (issueId: string) {
403
+ function* (_project, issueId) {
376
404
  yield* updateIssue({
377
405
  owner: cli.owner,
378
406
  repo: cli.repo,
@@ -382,8 +410,34 @@ export const GithubIssueSource = Layer.effect(
382
410
  },
383
411
  Effect.mapError((cause) => new IssueSourceError({ cause })),
384
412
  ),
413
+ reset: Effect.gen(function* () {
414
+ const projectId = yield* CurrentProjectId
415
+ yield* Settings.setProject(labelFilter, Option.none())
416
+ yield* Settings.setProject(autoMergeLabel, Option.none())
417
+ yield* Cache.invalidate(projectSettings, projectId)
418
+ }),
419
+ settings: (projectId) =>
420
+ Effect.asVoid(Cache.get(projectSettings, projectId)),
421
+ info: Effect.fnUntraced(function* (projectId) {
422
+ const { labelFilter, autoMergeLabelName } = yield* Cache.get(
423
+ projectSettings,
424
+ projectId,
425
+ )
426
+ console.log(
427
+ ` Label filter: ${Option.match(labelFilter, {
428
+ onNone: () => "None",
429
+ onSome: (value) => value,
430
+ })}`,
431
+ )
432
+ console.log(
433
+ ` Auto-merge label: ${Option.match(autoMergeLabelName, {
434
+ onNone: () => "Disabled",
435
+ onSome: (value) => value,
436
+ })}`,
437
+ )
438
+ }),
385
439
  ensureInProgress: Effect.fnUntraced(
386
- function* (issueId: string) {
440
+ function* (_project, issueId) {
387
441
  const issueNumber = Number(issueId.slice(1))
388
442
  yield* pipe(
389
443
  github.request((rest) =>
@@ -403,7 +457,14 @@ export const GithubIssueSource = Layer.effect(
403
457
  ),
404
458
  })
405
459
  }),
406
- ).pipe(Layer.provide([Github.layer, GithubCli.layer, Reactivity.layer]))
460
+ ).pipe(
461
+ Layer.provide([
462
+ Github.layer,
463
+ GithubCli.layer,
464
+ Reactivity.layer,
465
+ Settings.layer,
466
+ ]),
467
+ )
407
468
 
408
469
  export class GithubRepoNotFound extends Data.TaggedError("GithubRepoNotFound") {
409
470
  readonly message = "GitHub repository not found"
@@ -411,7 +472,7 @@ export class GithubRepoNotFound extends Data.TaggedError("GithubRepoNotFound") {
411
472
 
412
473
  // == label filter
413
474
 
414
- const labelFilter = new Setting(
475
+ const labelFilter = new ProjectSetting(
415
476
  "github.labelFilter",
416
477
  Schema.Option(Schema.String),
417
478
  )
@@ -423,11 +484,11 @@ const labelSelect = Effect.gen(function* () {
423
484
  const labelOption = Option.some(label.trim()).pipe(
424
485
  Option.filter(String.isNonEmpty),
425
486
  )
426
- yield* labelFilter.set(Option.some(labelOption))
487
+ yield* Settings.setProject(labelFilter, Option.some(labelOption))
427
488
  return labelOption
428
489
  })
429
490
  const getOrSelectLabel = Effect.gen(function* () {
430
- const label = yield* labelFilter.get
491
+ const label = yield* Settings.getProject(labelFilter)
431
492
  if (Option.isSome(label)) {
432
493
  return label.value
433
494
  }
@@ -436,7 +497,7 @@ const getOrSelectLabel = Effect.gen(function* () {
436
497
 
437
498
  // == auto merge label
438
499
 
439
- const autoMergeLabel = new Setting(
500
+ const autoMergeLabel = new ProjectSetting(
440
501
  "github.autoMergeLabel",
441
502
  Schema.Option(Schema.String),
442
503
  )
@@ -448,21 +509,17 @@ const autoMergeLabelSelect = Effect.gen(function* () {
448
509
  const labelOption = Option.some(label.trim()).pipe(
449
510
  Option.filter(String.isNonEmpty),
450
511
  )
451
- yield* autoMergeLabel.set(Option.some(labelOption))
512
+ yield* Settings.setProject(autoMergeLabel, Option.some(labelOption))
452
513
  return labelOption
453
514
  })
454
515
  const getOrSelectAutoMergeLabel = Effect.gen(function* () {
455
- const label = yield* autoMergeLabel.get
516
+ const label = yield* Settings.getProject(autoMergeLabel)
456
517
  if (Option.isSome(label)) {
457
518
  return label.value
458
519
  }
459
520
  return yield* autoMergeLabelSelect
460
521
  })
461
522
 
462
- export const resetGithub = labelFilter
463
- .set(Option.none())
464
- .pipe(Effect.andThen(autoMergeLabel.set(Option.none())))
465
-
466
523
  // == helpers
467
524
 
468
525
  const maybeNextPage = (page: number, linkHeader?: string) =>
@@ -1,17 +1,23 @@
1
1
  import { Effect, Schema, ServiceMap } from "effect"
2
2
  import type { PrdIssue } from "./domain/PrdIssue.ts"
3
3
  import { Reactivity } from "effect/unstable/reactivity"
4
+ import type { ProjectId } from "./domain/Project.ts"
5
+ import type { CurrentProjectId, Settings } from "./Settings.ts"
4
6
 
5
7
  export class IssueSource extends ServiceMap.Service<
6
8
  IssueSource,
7
9
  {
8
- readonly issues: Effect.Effect<ReadonlyArray<PrdIssue>, IssueSourceError>
10
+ readonly issues: (
11
+ projectId: ProjectId,
12
+ ) => Effect.Effect<ReadonlyArray<PrdIssue>, IssueSourceError>
9
13
 
10
14
  readonly createIssue: (
15
+ projectId: ProjectId,
11
16
  issue: PrdIssue,
12
17
  ) => Effect.Effect<{ id: string; url: string }, IssueSourceError>
13
18
 
14
19
  readonly updateIssue: (options: {
20
+ readonly projectId: ProjectId
15
21
  readonly issueId: string
16
22
  readonly title?: string
17
23
  readonly description?: string
@@ -21,10 +27,24 @@ export class IssueSource extends ServiceMap.Service<
21
27
  }) => Effect.Effect<void, IssueSourceError>
22
28
 
23
29
  readonly cancelIssue: (
30
+ projectId: ProjectId,
24
31
  issueId: string,
25
32
  ) => Effect.Effect<void, IssueSourceError>
26
33
 
34
+ readonly reset: Effect.Effect<
35
+ void,
36
+ IssueSourceError,
37
+ CurrentProjectId | Settings
38
+ >
39
+ readonly settings: (
40
+ projectId: ProjectId,
41
+ ) => Effect.Effect<void, IssueSourceError>
42
+ readonly info: (
43
+ projectId: ProjectId,
44
+ ) => Effect.Effect<void, IssueSourceError>
45
+
27
46
  readonly ensureInProgress: (
47
+ projectId: ProjectId,
28
48
  issueId: string,
29
49
  ) => Effect.Effect<void, IssueSourceError>
30
50
  }
@@ -34,12 +54,27 @@ export class IssueSource extends ServiceMap.Service<
34
54
  const reactivity = yield* Reactivity.Reactivity
35
55
  return IssueSource.of({
36
56
  ...impl,
37
- createIssue: (issue) =>
38
- reactivity.mutation(["issues"], impl.createIssue(issue)),
57
+ createIssue: (projectId, issue) =>
58
+ reactivity.mutation(
59
+ {
60
+ issues: [projectId],
61
+ },
62
+ impl.createIssue(projectId, issue),
63
+ ),
39
64
  updateIssue: (options) =>
40
- reactivity.mutation(["issues"], impl.updateIssue(options)),
41
- cancelIssue: (issueId) =>
42
- reactivity.mutation(["issues"], impl.cancelIssue(issueId)),
65
+ reactivity.mutation(
66
+ {
67
+ issues: [options.projectId],
68
+ },
69
+ impl.updateIssue(options),
70
+ ),
71
+ cancelIssue: (projectId, issueId) =>
72
+ reactivity.mutation(
73
+ {
74
+ issues: [projectId],
75
+ },
76
+ impl.cancelIssue(projectId, issueId),
77
+ ),
43
78
  })
44
79
  })
45
80
  }
@@ -1,27 +1,26 @@
1
1
  import { Effect, Layer, Option, pipe, Schema, ServiceMap } from "effect"
2
- import { Setting, Settings } from "./Settings.ts"
3
- import { LinearIssueSource, resetLinear } from "./Linear.ts"
2
+ import { CurrentProjectId, Setting, Settings } from "./Settings.ts"
3
+ import { LinearIssueSource } from "./Linear.ts"
4
4
  import { Prompt } from "effect/unstable/cli"
5
- import { GithubIssueSource, resetGithub } from "./Github.ts"
5
+ import { GithubIssueSource } from "./Github.ts"
6
6
  import { IssueSource } from "./IssueSource.ts"
7
7
  import { PlatformServices } from "./shared/platform.ts"
8
8
  import { atomRuntime } from "./shared/runtime.ts"
9
9
  import { Atom, Reactivity } from "effect/unstable/reactivity"
10
10
  import type { PrdIssue } from "./domain/PrdIssue.ts"
11
+ import type { ProjectId } from "./domain/Project.ts"
11
12
 
12
13
  const issueSources: ReadonlyArray<typeof CurrentIssueSource.Service> = [
13
14
  {
14
15
  id: "linear",
15
16
  name: "Linear",
16
17
  layer: LinearIssueSource,
17
- reset: resetLinear,
18
18
  githubPrInstructions: `The title of the PR should include the task id.`,
19
19
  },
20
20
  {
21
21
  id: "github",
22
22
  name: "GitHub Issues",
23
23
  layer: GithubIssueSource,
24
- reset: resetGithub,
25
24
  githubPrInstructions: `At the start of your PR description, include a line that closes the issue: Closes {task id}`,
26
25
  },
27
26
  ]
@@ -39,24 +38,18 @@ export const selectIssueSource = Effect.gen(function* () {
39
38
  value: s,
40
39
  })),
41
40
  })
42
- yield* selectedIssueSource.set(Option.some(source.id))
43
- yield* source.reset
41
+ yield* Settings.set(selectedIssueSource, Option.some(source.id))
44
42
  return source
45
43
  })
46
44
 
47
45
  const getOrSelectIssueSource = Effect.gen(function* () {
48
- const issueSource = yield* selectedIssueSource.get
46
+ const issueSource = yield* Settings.get(selectedIssueSource)
49
47
  if (Option.isSome(issueSource)) {
50
48
  return issueSources.find((s) => s.id === issueSource.value)!
51
49
  }
52
50
  return yield* selectIssueSource
53
51
  })
54
52
 
55
- export const resetCurrentIssueSource = Effect.gen(function* () {
56
- const source = yield* getOrSelectIssueSource
57
- yield* source.reset
58
- })
59
-
60
53
  export class CurrentIssueSource extends ServiceMap.Service<
61
54
  CurrentIssueSource,
62
55
  {
@@ -67,7 +60,6 @@ export class CurrentIssueSource extends ServiceMap.Service<
67
60
  Layer.Error<typeof LinearIssueSource | typeof GithubIssueSource>,
68
61
  Layer.Services<typeof LinearIssueSource | typeof GithubIssueSource>
69
62
  >
70
- readonly reset: Effect.Effect<void, never, Settings>
71
63
  readonly githubPrInstructions: string
72
64
  }
73
65
  >()("lalph/CurrentIssueSource") {
@@ -88,32 +80,36 @@ export const issueSourceRuntime = atomRuntime(
88
80
  CurrentIssueSource.layer.pipe(Layer.orDie),
89
81
  )
90
82
 
91
- export const currentIssuesAtom = pipe(
92
- issueSourceRuntime.atom(
93
- Effect.fnUntraced(function* (get) {
94
- const source = yield* IssueSource
95
- const issues = yield* source.issues.pipe(
96
- Effect.withSpan("currentIssuesAtom.refresh"),
97
- )
98
- const handle = setTimeout(() => {
99
- get.refreshSelf()
100
- }, 30_000)
101
- get.addFinalizer(() => clearTimeout(handle))
102
- return issues
103
- }),
83
+ export const currentIssuesAtom = Atom.family((projectId: ProjectId) =>
84
+ pipe(
85
+ issueSourceRuntime.atom(
86
+ Effect.fnUntraced(function* (get) {
87
+ const source = yield* IssueSource
88
+ const issues = yield* source
89
+ .issues(projectId)
90
+ .pipe(Effect.withSpan("currentIssuesAtom.refresh"))
91
+ const handle = setTimeout(() => {
92
+ get.refreshSelf()
93
+ }, 30_000)
94
+ get.addFinalizer(() => clearTimeout(handle))
95
+ return issues
96
+ }),
97
+ ),
98
+ atomRuntime.withReactivity([`issues:${projectId}`]),
99
+ Atom.keepAlive,
104
100
  ),
105
- atomRuntime.withReactivity(["issues"]),
106
- Atom.keepAlive,
107
101
  )
108
102
 
109
103
  // Helpers
110
104
 
111
- const getCurrentIssues = Atom.getResult(currentIssuesAtom, {
112
- suspendOnWaiting: true,
113
- })
105
+ const getCurrentIssues = (projectId: ProjectId) =>
106
+ Atom.getResult(currentIssuesAtom(projectId), {
107
+ suspendOnWaiting: true,
108
+ })
114
109
 
115
110
  export const checkForWork = Effect.gen(function* () {
116
- const issues = yield* getCurrentIssues
111
+ const projectId = yield* CurrentProjectId
112
+ const issues = yield* getCurrentIssues(projectId)
117
113
  const hasIncomplete = issues.some(
118
114
  (issue) => issue.state === "todo" && issue.blockedBy.length === 0,
119
115
  )
@@ -125,7 +121,8 @@ export const checkForWork = Effect.gen(function* () {
125
121
  export const resetInProgress = Effect.gen(function* () {
126
122
  const source = yield* IssueSource
127
123
  const reactivity = yield* Reactivity.Reactivity
128
- const issues = yield* getCurrentIssues
124
+ const projectId = yield* CurrentProjectId
125
+ const issues = yield* getCurrentIssues(projectId)
129
126
  const inProgress = issues.filter(
130
127
  (issue): issue is PrdIssue & { id: string } =>
131
128
  issue.state === "in-progress" && issue.id !== null,
@@ -135,6 +132,7 @@ export const resetInProgress = Effect.gen(function* () {
135
132
  inProgress,
136
133
  (issue) =>
137
134
  source.updateIssue({
135
+ projectId,
138
136
  issueId: issue.id,
139
137
  state: "todo",
140
138
  }),
package/src/Kvs.ts CHANGED
@@ -1,7 +1,19 @@
1
- import { Layer } from "effect"
1
+ import { Layer, LayerMap } from "effect"
2
2
  import { KeyValueStore } from "effect/unstable/persistence"
3
3
  import { PlatformServices } from "./shared/platform.ts"
4
+ import { ProjectId } from "./domain/Project.ts"
4
5
 
5
6
  export const layerKvs = KeyValueStore.layerFileSystem(".lalph/config").pipe(
6
7
  Layer.provide(PlatformServices),
7
8
  )
9
+
10
+ export class ProjectsKvs extends LayerMap.Service<ProjectsKvs>()(
11
+ "lalph/ProjectsKvs",
12
+ {
13
+ lookup: (projectId: ProjectId) =>
14
+ KeyValueStore.layerFileSystem(
15
+ `.lalph/projects/${encodeURIComponent(projectId)}`,
16
+ ).pipe(Layer.orDie),
17
+ dependencies: [PlatformServices],
18
+ },
19
+ ) {}