Skip to content

Commit 19739d5

Browse files
committed
feat(sdk-commands): namespace local world storage by scene coordinates (pre-existing WIP)
1 parent 5ffe873 commit 19739d5

8 files changed

Lines changed: 223 additions & 38 deletions

File tree

packages/@dcl/sdk-commands/src/commands/start/server/runtime-env.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from 'path'
22
import { CliComponents } from '../../../components'
3+
import { getObject } from '../../../logic/coordinates'
34

45
// Find the sdk-commands package root by resolving its package.json
56
const SDK_COMMANDS_ROOT = path.dirname(require.resolve('@dcl/sdk-commands/package.json'))
@@ -9,18 +10,34 @@ const SERVER_STORAGE_FILE = 'server-storage.json'
910
/**
1011
* Structure for all server-side storage data.
1112
* Stored in sdk-commands package directory (hidden from users).
13+
*
14+
* `world` is namespaced by scene base coordinates (`"x,y"`) so that previewing
15+
* different scenes does not share the same scene-storage bucket.
1216
*/
1317
export interface ServerStorage {
1418
env: Record<string, string>
15-
world: Record<string, unknown>
19+
world: Record<string, Record<string, unknown>>
1620
players: Record<string, Record<string, unknown>>
1721
}
1822

19-
const DEFAULT_STORAGE: ServerStorage = {
23+
/**
24+
* Normalizes a scene base parcel (e.g. `"60, -9"`) into the canonical `"x,y"`
25+
* key used to namespace world storage, so equivalent spellings map to one bucket.
26+
*/
27+
export function getSceneStorageKey(base: string): string {
28+
const { x, y } = getObject(base)
29+
return `${x},${y}`
30+
}
31+
32+
const isPlainObject = (value: unknown): boolean => typeof value === 'object' && value !== null && !Array.isArray(value)
33+
34+
// Factory (not a shared const): each call returns fresh nested objects so callers
35+
// can never mutate a shared default and leak state into later loads.
36+
const createDefaultStorage = (): ServerStorage => ({
2037
env: {},
2138
world: {},
2239
players: {}
23-
}
40+
})
2441

2542
/**
2643
* Ensures the runtime data directory exists.
@@ -45,21 +62,31 @@ export async function loadServerStorage(components: Pick<CliComponents, 'fs' | '
4562
try {
4663
const exists = await components.fs.fileExists(storagePath)
4764
if (!exists) {
48-
return { ...DEFAULT_STORAGE }
65+
return createDefaultStorage()
4966
}
5067

5168
const content = await components.fs.readFile(storagePath, 'utf-8')
5269
const parsed = JSON.parse(content) as Partial<ServerStorage>
5370

71+
// `world` is now namespaced by scene coordinates (`"x,y"` -> key -> value).
72+
// A file with any non-object top-level `world` entry predates that change
73+
// (flat `key -> value`); its data has no scene to attribute to, so discard it.
74+
// Local preview storage is disposable, so resetting is preferable to migrating.
75+
const rawWorld = parsed.world ?? {}
76+
const isLegacyWorld = Object.values(rawWorld).some((value) => !isPlainObject(value))
77+
if (isLegacyWorld) {
78+
components.logger.debug('Resetting legacy local preview world storage (pre scene-coordinate namespacing)')
79+
}
80+
5481
// Merge with defaults to ensure all keys exist
5582
return {
5683
env: parsed.env ?? {},
57-
world: parsed.world ?? {},
84+
world: isLegacyWorld ? {} : rawWorld,
5885
players: parsed.players ?? {}
5986
}
6087
} catch (error) {
6188
components.logger.error(`Failed to load ${SERVER_STORAGE_FILE}: ${error}`)
62-
return { ...DEFAULT_STORAGE }
89+
return createDefaultStorage()
6390
}
6491
}
6592

@@ -183,52 +210,59 @@ export async function deleteEnvValue(components: Pick<CliComponents, 'fs' | 'log
183210
}
184211

185212
/**
186-
* Gets all world storage data.
213+
* Gets all world storage data for a scene, keyed by its base-coordinate bucket.
187214
*/
188215
export async function getWorldStorage(
189-
components: Pick<CliComponents, 'fs' | 'logger'>
216+
components: Pick<CliComponents, 'fs' | 'logger'>,
217+
sceneKey: string
190218
): Promise<Record<string, unknown>> {
191219
const storage = await loadServerStorage(components)
192-
return storage.world
220+
return storage.world[sceneKey] ?? {}
193221
}
194222

195223
/**
196-
* Gets a value from world storage.
224+
* Gets a value from a scene's world storage.
197225
*/
198226
export async function getWorldValue(
199227
components: Pick<CliComponents, 'fs' | 'logger'>,
228+
sceneKey: string,
200229
key: string
201230
): Promise<unknown | undefined> {
202231
const storage = await loadServerStorage(components)
203-
return storage.world[key]
232+
return storage.world[sceneKey]?.[key]
204233
}
205234

206235
/**
207-
* Sets a value in world storage.
236+
* Sets a value in a scene's world storage.
208237
*/
209238
export async function setWorldValue(
210239
components: Pick<CliComponents, 'fs' | 'logger'>,
240+
sceneKey: string,
211241
key: string,
212242
value: unknown
213243
): Promise<void> {
214244
const storage = await loadServerStorage(components)
215-
storage.world[key] = value
245+
if (!storage.world[sceneKey]) {
246+
storage.world[sceneKey] = {}
247+
}
248+
storage.world[sceneKey][key] = value
216249
await saveServerStorage(components, storage)
217250
}
218251

219252
/**
220-
* Deletes a value from world storage.
253+
* Deletes a value from a scene's world storage.
221254
* Returns true if key existed and was deleted, false otherwise.
222255
*/
223256
export async function deleteWorldValue(
224257
components: Pick<CliComponents, 'fs' | 'logger'>,
258+
sceneKey: string,
225259
key: string
226260
): Promise<boolean> {
227261
const storage = await loadServerStorage(components)
228-
if (!(key in storage.world)) {
262+
if (!storage.world[sceneKey] || !(key in storage.world[sceneKey])) {
229263
return false
230264
}
231-
delete storage.world[key]
265+
delete storage.world[sceneKey][key]
232266
await saveServerStorage(components, storage)
233267
return true
234268
}

packages/@dcl/sdk-commands/src/commands/start/server/storage-service.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
setEnvValue,
99
deleteEnvValue,
1010
loadServerStorage,
11+
getSceneStorageKey,
12+
getWorldStorage,
1113
getWorldValue,
1214
setWorldValue,
1315
deleteWorldValue,
@@ -24,6 +26,10 @@ export function setupStorageEndpoints(
2426
router: Router<PreviewComponents>,
2527
workspace: Workspace
2628
) {
29+
// World (scene) storage is namespaced by the previewed scene's base coordinates,
30+
// read directly from its scene.json (same source as routes.ts uses for the QR link).
31+
const sceneKey = getSceneStorageKey(workspace.projects[0].scene.scene.base)
32+
2733
const withKeyValidation: IHttpServerComponent.IRequestHandler<
2834
IHttpServerComponent.PathAwareContext<PreviewComponents, string>
2935
> = async (ctx, next) => {
@@ -88,8 +94,8 @@ export function setupStorageEndpoints(
8894
const limitParam = ctx.url.searchParams.get('limit')
8995
const offsetParam = ctx.url.searchParams.get('offset')
9096

91-
const storage = await loadServerStorage(components)
92-
let entries = Object.entries(storage.world).map(([key, value]) => ({ key, value }))
97+
const world = await getWorldStorage(components, sceneKey)
98+
let entries = Object.entries(world).map(([key, value]) => ({ key, value }))
9399

94100
if (prefix !== null && prefix !== '') {
95101
entries = entries.filter((entry) => entry.key.startsWith(prefix))
@@ -107,7 +113,7 @@ export function setupStorageEndpoints(
107113
router.get('/values/:key', withKeyValidation, async (ctx) => {
108114
const { key } = ctx.params
109115

110-
const value = await getWorldValue(components, key)
116+
const value = await getWorldValue(components, sceneKey, key)
111117
if (value === undefined) {
112118
return { status: 404, body: { message: `Storage key '${key}' not found` } }
113119
}
@@ -120,7 +126,7 @@ export function setupStorageEndpoints(
120126
try {
121127
const bodyText = await ctx.request.text()
122128
const { value } = JSON.parse(bodyText)
123-
await setWorldValue(components, key, value)
129+
await setWorldValue(components, sceneKey, key, value)
124130
return { body: JSON.stringify({ value }) }
125131
} catch (error) {
126132
components.logger.error(`Failed to set storage value '${key}': ${error}`)
@@ -132,7 +138,7 @@ export function setupStorageEndpoints(
132138
const { key } = ctx.params
133139

134140
try {
135-
await deleteWorldValue(components, key)
141+
await deleteWorldValue(components, sceneKey, key)
136142
return { status: 204 }
137143
} catch (error) {
138144
components.logger.error(`Failed to delete storage value '${key}': ${error}`)

packages/@dcl/sdk-commands/src/commands/storage/env.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
createStorageInfo,
88
makeAuthenticatedRequest,
99
confirmAction,
10-
getLinkerDappOptions
10+
getLinkerDappOptions,
11+
withPosition
1112
} from './shared'
1213

1314
/**
@@ -43,7 +44,7 @@ export const handleEnv = async (action: string, key: string | undefined, options
4344

4445
logger.info(`Setting environment variable '${key}' to ${baseURL}`)
4546

46-
const url = `${baseURL}/env/${encodeURIComponent(key)}`
47+
const url = withPosition(`${baseURL}/env/${encodeURIComponent(key)}`, baseParcel)
4748
const info = createStorageInfo('env', 'set', url, worldName, baseParcel, parcels, key, value)
4849

4950
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'PUT', url, { value })
@@ -63,7 +64,7 @@ export const handleEnv = async (action: string, key: string | undefined, options
6364

6465
logger.info(`Deleting environment variable '${key}' from ${baseURL}`)
6566

66-
const url = `${baseURL}/env/${encodeURIComponent(key)}`
67+
const url = withPosition(`${baseURL}/env/${encodeURIComponent(key)}`, baseParcel)
6768
const info = createStorageInfo('env', 'delete', url, worldName, baseParcel, parcels, key)
6869

6970
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url)
@@ -91,7 +92,7 @@ export const handleEnv = async (action: string, key: string | undefined, options
9192

9293
logger.info(`Clearing all environment variables from ${baseURL}`)
9394

94-
const url = `${baseURL}/env`
95+
const url = withPosition(`${baseURL}/env`, baseParcel)
9596
const info = createStorageInfo('env', 'clear', url, worldName, baseParcel, parcels)
9697

9798
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url, undefined, {

packages/@dcl/sdk-commands/src/commands/storage/player.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
createStorageInfo,
88
makeAuthenticatedRequest,
99
confirmAction,
10-
getLinkerDappOptions
10+
getLinkerDappOptions,
11+
withPosition
1112
} from './shared'
1213

1314
/**
@@ -48,7 +49,10 @@ export const handlePlayer = async (action: string, key: string | undefined, opti
4849

4950
logger.info(`Getting player storage value '${key}' for ${address} from ${baseURL}`)
5051

51-
const url = `${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
52+
const url = withPosition(
53+
`${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`,
54+
baseParcel
55+
)
5256
const info = createStorageInfo('player', 'get', url, worldName, baseParcel, parcels, key, undefined, address)
5357

5458
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'GET', url)
@@ -91,7 +95,10 @@ export const handlePlayer = async (action: string, key: string | undefined, opti
9195

9296
logger.info(`Setting player storage value '${key}' for ${address} to ${baseURL}`)
9397

94-
const url = `${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
98+
const url = withPosition(
99+
`${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`,
100+
baseParcel
101+
)
95102
const info = createStorageInfo('player', 'set', url, worldName, baseParcel, parcels, key, value, address)
96103

97104
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'PUT', url, { value })
@@ -125,7 +132,10 @@ export const handlePlayer = async (action: string, key: string | undefined, opti
125132

126133
logger.info(`Deleting player storage value '${key}' for ${address} from ${baseURL}`)
127134

128-
const url = `${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`
135+
const url = withPosition(
136+
`${baseURL}/players/${encodeURIComponent(address)}/values/${encodeURIComponent(key)}`,
137+
baseParcel
138+
)
129139
const info = createStorageInfo('player', 'delete', url, worldName, baseParcel, parcels, key, undefined, address)
130140

131141
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url)
@@ -159,7 +169,7 @@ export const handlePlayer = async (action: string, key: string | undefined, opti
159169

160170
logger.info(`Clearing all storage data for player ${address} from ${baseURL}`)
161171

162-
const url = `${baseURL}/players/${encodeURIComponent(address)}/values`
172+
const url = withPosition(`${baseURL}/players/${encodeURIComponent(address)}/values`, baseParcel)
163173
const info = createStorageInfo(
164174
'player',
165175
'clear',
@@ -201,7 +211,7 @@ export const handlePlayer = async (action: string, key: string | undefined, opti
201211

202212
logger.info(`Clearing all player storage data from ${baseURL}`)
203213

204-
const url = `${baseURL}/players`
214+
const url = withPosition(`${baseURL}/players`, baseParcel)
205215
const info = createStorageInfo('player', 'clear', url, worldName, baseParcel, parcels)
206216

207217
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url, undefined, {

packages/@dcl/sdk-commands/src/commands/storage/scene.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
createStorageInfo,
88
makeAuthenticatedRequest,
99
confirmAction,
10-
getLinkerDappOptions
10+
getLinkerDappOptions,
11+
withPosition
1112
} from './shared'
1213

1314
/**
@@ -38,7 +39,7 @@ export const handleScene = async (action: string, key: string | undefined, optio
3839

3940
logger.info(`Getting scene storage value '${key}' from ${baseURL}`)
4041

41-
const url = `${baseURL}/values/${encodeURIComponent(key)}`
42+
const url = withPosition(`${baseURL}/values/${encodeURIComponent(key)}`, baseParcel)
4243
const info = createStorageInfo('scene', 'get', url, worldName, baseParcel, parcels, key)
4344

4445
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'GET', url)
@@ -64,7 +65,7 @@ export const handleScene = async (action: string, key: string | undefined, optio
6465

6566
logger.info(`Setting scene storage value '${key}' to ${baseURL}`)
6667

67-
const url = `${baseURL}/values/${encodeURIComponent(key)}`
68+
const url = withPosition(`${baseURL}/values/${encodeURIComponent(key)}`, baseParcel)
6869
const info = createStorageInfo('scene', 'set', url, worldName, baseParcel, parcels, key, value)
6970

7071
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'PUT', url, { value })
@@ -84,7 +85,7 @@ export const handleScene = async (action: string, key: string | undefined, optio
8485

8586
logger.info(`Deleting scene storage value '${key}' from ${baseURL}`)
8687

87-
const url = `${baseURL}/values/${encodeURIComponent(key)}`
88+
const url = withPosition(`${baseURL}/values/${encodeURIComponent(key)}`, baseParcel)
8889
const info = createStorageInfo('scene', 'delete', url, worldName, baseParcel, parcels, key)
8990

9091
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url)
@@ -112,7 +113,7 @@ export const handleScene = async (action: string, key: string | undefined, optio
112113

113114
logger.info(`Clearing all scene storage data from ${baseURL}`)
114115

115-
const url = `${baseURL}/values`
116+
const url = withPosition(`${baseURL}/values`, baseParcel)
116117
const info = createStorageInfo('scene', 'clear', url, worldName, baseParcel, parcels)
117118

118119
const result = await makeAuthenticatedRequest(options.components, info, linkOptions, 'DELETE', url, undefined, {

packages/@dcl/sdk-commands/src/commands/storage/shared.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,27 @@ export const validateWorkspaceAndWorld = async (
3939
)
4040
}
4141

42-
const baseParcel = sceneJson.scene?.base || '0,0'
43-
const parcels = sceneJson.scene?.parcels || ['0,0']
42+
// getValidSceneJson (above) already validated the scene, so scene.base and
43+
// scene.parcels are guaranteed to exist. Use them directly instead of a '0,0'
44+
// fallback, which would silently target the wrong scene on the storage service.
45+
const baseParcel = sceneJson.scene.base
46+
const parcels = sceneJson.scene.parcels
4447

4548
return { worldName, baseParcel, parcels }
4649
}
4750

51+
/**
52+
* Appends the scene base parcel as a `position` query param to a storage URL.
53+
* The Server Side Storage service resolves which scene a request targets from
54+
* this param; without it the service defaults to '0,0' and rejects world requests.
55+
*/
56+
export const withPosition = (url: string, baseParcel: string): string => {
57+
// Keep the "x,y" comma literal (not percent-encoded) to match the convention used
58+
// elsewhere (e.g. deploy's `&position=${scene.base}`) and what the service expects.
59+
const separator = url.includes('?') ? '&' : '?'
60+
return `${url}${separator}position=${baseParcel}`
61+
}
62+
4863
/**
4964
* Builds metadata for server-side storage service requests (ADR-44 format)
5065
*/

0 commit comments

Comments
 (0)