@stacksjs/defaults 0.72.85 → 0.72.89

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.
@@ -12,7 +12,10 @@ export default new Action({
12
12
  apiResponse: true,
13
13
  async handle(request: RequestInstance) {
14
14
  const input = request.all() as Record<string, unknown>
15
- const plan = await migrationPlan()
15
+ // Fresh, not cached: the revision below is an optimistic-concurrency gate,
16
+ // and checking a caller's token against a plan computed up to a TTL ago
17
+ // would admit exactly the drift the gate is here to catch.
18
+ const plan = await migrationPlan({ fresh: true })
16
19
  if (stringValue(input.revision) !== plan.revision)
17
20
  return response.json({ message: 'The migration state changed. Refresh before reconciling.' }, 409)
18
21
  if (stringValue(input.confirmation) !== `reconcile ${plan.environment}`)
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { cachedComputation } from './cached-computation'
3
+
4
+ function counter() {
5
+ let calls = 0
6
+ let clock = 1_000
7
+ const cache = cachedComputation({
8
+ ttlMs: 30_000,
9
+ now: () => clock,
10
+ compute: async () => {
11
+ calls += 1
12
+ return `value-${calls}`
13
+ },
14
+ })
15
+ return {
16
+ cache,
17
+ calls: () => calls,
18
+ advance: (ms: number) => { clock += ms },
19
+ }
20
+ }
21
+
22
+ describe('cachedComputation', () => {
23
+ it('computes once and serves the same value inside the window', async () => {
24
+ const { cache, calls } = counter()
25
+
26
+ expect(await cache.get()).toBe('value-1')
27
+ expect(await cache.get()).toBe('value-1')
28
+ expect(await cache.get()).toBe('value-1')
29
+ expect(calls()).toBe(1)
30
+ })
31
+
32
+ it('recomputes once the value has aged past the TTL', async () => {
33
+ const { cache, calls, advance } = counter()
34
+
35
+ await cache.get()
36
+ advance(29_999)
37
+ expect(await cache.get()).toBe('value-1')
38
+ expect(calls()).toBe(1)
39
+
40
+ advance(1)
41
+ expect(await cache.get()).toBe('value-2')
42
+ expect(calls()).toBe(2)
43
+ })
44
+
45
+ it('recomputes on demand and reseeds the cache with that value', async () => {
46
+ const { cache, calls } = counter()
47
+
48
+ await cache.get()
49
+ expect(await cache.get({ fresh: true })).toBe('value-2')
50
+ expect(calls()).toBe(2)
51
+
52
+ // The forced read is not a one-off: later readers get what it computed,
53
+ // rather than the value it replaced.
54
+ expect(await cache.get()).toBe('value-2')
55
+ expect(calls()).toBe(2)
56
+ })
57
+
58
+ it('recomputes after an explicit invalidation', async () => {
59
+ const { cache, calls } = counter()
60
+
61
+ await cache.get()
62
+ cache.invalidate()
63
+ expect(await cache.get()).toBe('value-2')
64
+ expect(calls()).toBe(2)
65
+ })
66
+
67
+ it('collapses concurrent readers onto one computation', async () => {
68
+ // The case this exists for: both operations pages loading at once used to
69
+ // run the same model-versus-schema diff twice for identical output.
70
+ let calls = 0
71
+ let release: (value: string) => void = () => {}
72
+ const gate = new Promise<string>((resolve) => { release = resolve })
73
+ const cache = cachedComputation({
74
+ ttlMs: 30_000,
75
+ compute: async () => {
76
+ calls += 1
77
+ return await gate
78
+ },
79
+ })
80
+
81
+ const readers = [cache.get(), cache.get(), cache.get()]
82
+ release('shared')
83
+
84
+ expect(await Promise.all(readers)).toEqual(['shared', 'shared', 'shared'])
85
+ expect(calls).toBe(1)
86
+ })
87
+
88
+ it('does not wedge later readers onto a computation that failed', async () => {
89
+ let attempt = 0
90
+ const cache = cachedComputation({
91
+ ttlMs: 30_000,
92
+ compute: async () => {
93
+ attempt += 1
94
+ if (attempt === 1)
95
+ throw new Error('first attempt failed')
96
+ return 'recovered'
97
+ },
98
+ })
99
+
100
+ await expect(cache.get()).rejects.toThrow('first attempt failed')
101
+ expect(await cache.get()).toBe('recovered')
102
+ expect(attempt).toBe(2)
103
+ })
104
+
105
+ it('does not let a forced read adopt a computation that started before it', async () => {
106
+ // `fresh` exists for callers gating a write on the answer. Joining a read
107
+ // already in flight would hand back a value computed before whatever made
108
+ // them ask, which is the staleness the flag is there to avoid.
109
+ let calls = 0
110
+ const releases: Array<(value: string) => void> = []
111
+ const cache = cachedComputation({
112
+ ttlMs: 30_000,
113
+ compute: async () => {
114
+ calls += 1
115
+ return await new Promise<string>((resolve) => { releases.push(resolve) })
116
+ },
117
+ })
118
+
119
+ const slowReader = cache.get()
120
+ const forced = cache.get({ fresh: true })
121
+ expect(calls).toBe(2)
122
+
123
+ releases[0]('stale')
124
+ releases[1]('current')
125
+ expect(await slowReader).toBe('stale')
126
+ expect(await forced).toBe('current')
127
+ })
128
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A single cached value with a time budget and no duplicate work in flight.
3
+ *
4
+ * Written for the dashboard's migration plan, which costs a full
5
+ * model-versus-schema diff plus a ledger audit to produce. Two read endpoints
6
+ * built one per request, so a page load cost seconds and moving between the
7
+ * two pages paid it again.
8
+ *
9
+ * Kept separate from the plan itself so the caching rules can be tested
10
+ * without a database behind them: pass a `now` and the TTL boundary is exact
11
+ * rather than a sleep.
12
+ */
13
+ export interface CachedComputationOptions<T> {
14
+ /** How long a computed value may be handed to a reader, in milliseconds. */
15
+ ttlMs: number
16
+ compute: () => Promise<T>
17
+ /** Injectable clock. Tests drive the TTL boundary with it. */
18
+ now?: () => number
19
+ }
20
+
21
+ export interface CachedComputation<T> {
22
+ /**
23
+ * Return the cached value, computing one if there is none or it has aged
24
+ * out. `fresh` forces a recomputation and reseeds the cache with it - for
25
+ * callers whose answer gates a write and so cannot be a moment old.
26
+ */
27
+ get: (options?: { fresh?: boolean }) => Promise<T>
28
+ /** Forget the cached value. The next `get` recomputes. */
29
+ invalidate: () => void
30
+ }
31
+
32
+ export function cachedComputation<T>(options: CachedComputationOptions<T>): CachedComputation<T> {
33
+ const now = options.now ?? Date.now
34
+ let cached: { value: T, computedAt: number } | null = null
35
+ let inFlight: Promise<T> | null = null
36
+
37
+ async function run(): Promise<T> {
38
+ // Callers arriving mid-computation join the one already running instead of
39
+ // starting a second. Without this the two operations pages loading
40
+ // together ran the same expensive diff concurrently for identical output.
41
+ if (inFlight)
42
+ return await inFlight
43
+
44
+ inFlight = options.compute()
45
+ try {
46
+ const value = await inFlight
47
+ cached = { value, computedAt: now() }
48
+ return value
49
+ }
50
+ finally {
51
+ // Cleared on failure too, so one rejected computation does not wedge
52
+ // every later caller onto the same rejected promise.
53
+ inFlight = null
54
+ }
55
+ }
56
+
57
+ return {
58
+ async get(getOptions: { fresh?: boolean } = {}): Promise<T> {
59
+ if (getOptions.fresh) {
60
+ cached = null
61
+ // Deliberately not joined to an in-flight read: that one may have
62
+ // started before whatever made this caller ask for a fresh value.
63
+ inFlight = null
64
+ return await run()
65
+ }
66
+
67
+ if (cached && now() - cached.computedAt < options.ttlMs)
68
+ return cached.value
69
+
70
+ return await run()
71
+ },
72
+ invalidate(): void {
73
+ cached = null
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * The migration plan is cached, so which callers opt out of that cache is a
7
+ * correctness property rather than a preference.
8
+ *
9
+ * `revision` is an optimistic-concurrency token: a caller sends back the
10
+ * revision it reviewed, and the server refuses the write if the plan has
11
+ * moved since. Compare that token against a plan computed up to a TTL ago and
12
+ * the gate admits exactly the drift it exists to catch. These read the source
13
+ * because the alternative is standing a database up to prove a call shape.
14
+ */
15
+ const directory = import.meta.dir
16
+
17
+ function source(file: string): string {
18
+ return readFileSync(join(directory, file), 'utf8')
19
+ }
20
+
21
+ describe('migration plan cache wiring', () => {
22
+ it('gates every write on a freshly computed plan', () => {
23
+ const gates = [
24
+ ['migration-operations.ts', 'applyMigrationPlan'],
25
+ ['MigrationReconcileAction.ts', 'MigrationReconcileAction'],
26
+ ] as const
27
+
28
+ for (const [file, gate] of gates) {
29
+ const text = source(file)
30
+ const calls = [...text.matchAll(/migrationPlan\(([^)]*)\)/g)]
31
+ .map(match => match[1].trim())
32
+ // The declaration itself, not a call.
33
+ .filter(argument => !argument.startsWith('options:'))
34
+
35
+ expect(calls.length, `${gate} should still ask for a plan`).toBeGreaterThan(0)
36
+ for (const argument of calls)
37
+ expect(argument, `${gate} must gate on a fresh plan`).toContain('fresh: true')
38
+ }
39
+ })
40
+
41
+ it('lets the read-only index endpoints use the cache', () => {
42
+ for (const file of ['ChangeIndexAction.ts', 'MigrationIndexAction.ts']) {
43
+ const calls = [...source(file).matchAll(/migrationPlan\(([^)]*)\)/g)].map(match => match[1].trim())
44
+ expect(calls.length, `${file} should still ask for a plan`).toBeGreaterThan(0)
45
+ for (const argument of calls)
46
+ expect(argument, `${file} should not force a recomputation per request`).toBe('')
47
+ }
48
+ })
49
+
50
+ it('drops the cached state after anything that moves the schema or ledger', () => {
51
+ const text = source('migration-operations.ts')
52
+
53
+ // After the migrate subprocess succeeds, and after a real (non-dry-run)
54
+ // ledger reconcile. Either one leaves the cached reading describing a
55
+ // state that no longer exists.
56
+ const invalidations = text.match(/invalidateMigrationPlan\(\)/g) ?? []
57
+ expect(invalidations.length).toBeGreaterThanOrEqual(3)
58
+ expect(text).toContain('reconcileMigrationLedger({ dryRun: false })')
59
+ })
60
+ })
@@ -1,5 +1,6 @@
1
1
  import process from 'node:process'
2
2
  import { auditMigrationLedger, previewPendingMigrations, reconcileMigrationLedger } from '@stacksjs/database'
3
+ import { cachedComputation } from './cached-computation'
3
4
 
4
5
  export interface MigrationPlanOperation {
5
6
  kind: string
@@ -18,7 +19,55 @@ export class MigrationOperationError extends Error {
18
19
 
19
20
  const destructiveKinds = new Set(['drop_table', 'drop_column', 'alter_column', 'truncate_table'])
20
21
 
21
- export async function migrationPlan() {
22
+ export type MigrationPlan = Awaited<ReturnType<typeof computeMigrationPlan>>
23
+
24
+ /**
25
+ * How long a computed plan may be served to a reader.
26
+ *
27
+ * Building one costs a full model-versus-schema diff plus a ledger audit -
28
+ * measured at 2.3s and 1.4s against 93 models, versus 14ms for a normal
29
+ * dashboard endpoint. `/operations/migrations` and `/operations/changes` both
30
+ * built one per request, so opening either page cost seconds and moving
31
+ * between them paid it twice.
32
+ *
33
+ * Thirty seconds is long enough to cover a page load, a tab switch between
34
+ * the two operations pages, and a couple of refreshes, and short enough that
35
+ * editing a model and reloading shows the new plan without a manual step.
36
+ * Anything that changes the schema from inside the dashboard invalidates
37
+ * explicitly, so the window only ever hides changes made elsewhere.
38
+ */
39
+ const PLAN_TTL_MS = 30_000
40
+
41
+ const planCache = cachedComputation({ ttlMs: PLAN_TTL_MS, compute: () => computeMigrationPlan() })
42
+
43
+ /**
44
+ * The ledger repair preview, cached on the same terms as the plan.
45
+ *
46
+ * `MigrationIndexAction` asks for both on every request, and a dry-run
47
+ * reconcile reads the same ledger the plan already audited - about 1.3s of
48
+ * the endpoint's cost, for a report that cannot have changed while the plan
49
+ * it sits next to has not.
50
+ */
51
+ const reconciliationCache = cachedComputation({
52
+ ttlMs: PLAN_TTL_MS,
53
+ compute: () => reconcileMigrationLedger({ dryRun: true }),
54
+ })
55
+
56
+ /**
57
+ * Drop the cached view of migration state.
58
+ *
59
+ * Applying a migration or reconciling the ledger changes the very state these
60
+ * describe, so holding them for the rest of the TTL would show an operator
61
+ * the work they just did as still pending. Both go together: the plan and the
62
+ * repair preview are two readings of one state, and keeping one while
63
+ * dropping the other would put the page into a shape neither describes.
64
+ */
65
+ export function invalidateMigrationPlan(): void {
66
+ planCache.invalidate()
67
+ reconciliationCache.invalidate()
68
+ }
69
+
70
+ async function computeMigrationPlan() {
22
71
  const [rawOperations, ledger] = await Promise.all([
23
72
  previewPendingMigrations(),
24
73
  auditMigrationLedger(),
@@ -41,6 +90,11 @@ export async function migrationPlan() {
41
90
  revision,
42
91
  applied: ledger.recordedCount,
43
92
  ledger,
93
+ // Stamped so a reader can tell how old the plan it is looking at is.
94
+ // Deliberately outside the `revision` hash above: revision identifies the
95
+ // schema state being approved, and must not change just because the same
96
+ // state was computed again a minute later.
97
+ computedAt: new Date().toISOString(),
44
98
  summary: {
45
99
  pending: operations.length,
46
100
  destructive: operations.filter(operation => operation.destructive).length,
@@ -50,8 +104,20 @@ export async function migrationPlan() {
50
104
  }
51
105
  }
52
106
 
107
+ /**
108
+ * The model-derived schema plan and the migration ledger's health.
109
+ *
110
+ * Served from a short-lived cache by default. Pass `fresh` when the answer is
111
+ * about to gate a write: `revision` is an optimistic-concurrency token, and
112
+ * comparing a caller's token against a cached plan would let a change that
113
+ * landed during the window through the gate it exists to close.
114
+ */
115
+ export async function migrationPlan(options: { fresh?: boolean } = {}): Promise<MigrationPlan> {
116
+ return await planCache.get(options)
117
+ }
118
+
53
119
  export async function applyMigrationPlan(input: { revision: string, confirmation: string }): Promise<{ message: string }> {
54
- const plan = await migrationPlan()
120
+ const plan = await migrationPlan({ fresh: true })
55
121
  if (input.revision !== plan.revision)
56
122
  throw new MigrationOperationError('The migration plan changed. Refresh and review the new plan before applying it.')
57
123
  if (input.confirmation !== `migrate ${plan.environment}`)
@@ -72,9 +138,22 @@ export async function applyMigrationPlan(input: { revision: string, confirmation
72
138
  ])
73
139
  if (exitCode !== 0)
74
140
  throw new MigrationOperationError(stderr.trim() || stdout.trim() || 'The migration command failed.')
141
+ // The schema just moved, so every cached plan describing the old one is
142
+ // wrong. Without this the operations pages would keep showing the applied
143
+ // work as pending for the rest of the TTL.
144
+ invalidateMigrationPlan()
75
145
  return { message: `Applied ${plan.operations.length} planned schema operation${plan.operations.length === 1 ? '' : 's'}.` }
76
146
  }
77
147
 
78
148
  export async function reconcileMigrationLedgerPlan(apply = false) {
79
- return await reconcileMigrationLedger({ dryRun: !apply })
149
+ // A dry run reports what it would repair and changes nothing, so it is
150
+ // cacheable. A real one rewrites the ledger that the plan's `applied`,
151
+ // `drift` and `ledgerIssues` are read from, so it runs for real every time
152
+ // and drops what the old ledger produced.
153
+ if (!apply)
154
+ return await reconciliationCache.get()
155
+
156
+ const result = await reconcileMigrationLedger({ dryRun: false })
157
+ invalidateMigrationPlan()
158
+ return result
80
159
  }
@@ -1,9 +1,9 @@
1
1
  import { createLocaleSwitchResponse } from '@stacksjs/i18n'
2
2
  import { existsSync } from 'node:fs'
3
- import { projectPath } from '@stacksjs/path'
3
+ import { siteConfigPath } from '@stacksjs/path'
4
4
 
5
5
  async function loadSiteI18n(): Promise<{ locales: string[], defaultLocale: string } | null> {
6
- const sitePath = projectPath('site.config.ts')
6
+ const sitePath = siteConfigPath()
7
7
  if (!existsSync(sitePath))
8
8
  return null
9
9
 
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.72.85",
5
+ "version": "0.72.89",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.85",
5
+ "version": "0.72.89",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@iconify-json/f7": "^1.2.2",
53
53
  "@iconify-json/hugeicons": "^1.2.27",
54
- "@stacksjs/mobile": "^0.72.85",
54
+ "@stacksjs/mobile": "^0.72.89",
55
55
  "@stacksjs/sanitizer": "^0.2.113"
56
56
  },
57
57
  "scripts": {
package/project/bootstrap CHANGED
@@ -92,10 +92,16 @@ ensure_pantry() {
92
92
  echo "Pantry installed to ${INSTALL_DIR}/pantry"
93
93
  }
94
94
 
95
+ is_stacks_app() {
96
+ [ -f "package.json" ] \
97
+ && [ -x "buddy" ] \
98
+ && grep -Eq '"stacks"[[:space:]]*:' package.json
99
+ }
100
+
95
101
  # --- Main ---
96
102
 
97
103
  # If we are outside a project, create one from the latest stable release.
98
- if [ ! -d "storage/framework/core" ]; then
104
+ if [ ! -d "storage/framework/core" ] && ! is_stacks_app; then
99
105
  if [ -z "$PROJECT_NAME" ]; then
100
106
  PROJECT_NAME="${STACKS_PROJECT:-stacks}"
101
107
 
@@ -142,7 +148,7 @@ if [ ! -d "storage/framework/core" ]; then
142
148
  fi
143
149
 
144
150
  # Detect if we're in a Stacks project
145
- if [ ! -d "storage/framework/core" ]; then
151
+ if [ ! -d "storage/framework/core" ] && ! is_stacks_app; then
146
152
  echo "Error: not inside a Stacks project."
147
153
  exit 1
148
154
  fi