diff --git a/package-lock.json b/package-lock.json index 552626ce1..ae1992ce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18967,7 +18967,7 @@ }, "src/puter-js": { "name": "@heyputer/puter.js", - "version": "2.2.0", + "version": "2.2.5", "license": "Apache-2.0", "dependencies": { "@heyputer/kv.js": "^0.2.1" diff --git a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js index 67f51f7ee..009cc5c96 100644 --- a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js +++ b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js @@ -28,6 +28,7 @@ const BaseService = require('../../services/BaseService'); * @property {function(): Promise} flush - Delete all key-value pairs in the store. * @property {(params: KVStoreUpdateParams) => Promise} update - Update nested values by key. * @property {(params: KVStoreAddParams) => Promise} add - Append values into list paths by key. + * @property {(params: KVStoreRemoveParams) => Promise} remove - Remove nested values by key. * @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} incr - Increment a numeric value by key. * @property {(params: {key:string, pathAndAmountMap: Record}) => Promise} decr - Decrement a numeric value by key. * @property {function(KVStoreExpireAtParams): Promise} expireAt - Set a key to expire at a specific UNIX timestamp (seconds). @@ -56,6 +57,10 @@ const BaseService = require('../../services/BaseService'); * @property {string} key - The key to update. * @property {Object.} pathAndValueMap - Map of period-joined paths to values to append. * + * @typedef {Object} KVStoreRemoveParams + * @property {string} key - The key to update. + * @property {string[]} paths - List of period-joined paths to remove. + * * @typedef {Object} KVStoreExpireAtParams * @property {string} key - The key to set expiration for. * @property {number} timestamp - UNIX timestamp (seconds) when the key should expire. @@ -137,6 +142,14 @@ class KVStoreInterfaceService extends BaseService { }, result: { type: 'json', description: 'The updated value' }, }, + remove: { + description: 'Remove nested values by key.', + parameters: { + key: { type: 'string', required: true }, + paths: { type: 'json', required: true, description: 'list of period-joined paths to remove' }, + }, + result: { type: 'json', description: 'The updated value' }, + }, incr: { description: 'Increment a value by key.', parameters: { diff --git a/src/backend/src/services/repositories/DDBClient.ts b/src/backend/src/services/repositories/DDBClient.ts index 83ba9e062..4992faaad 100644 --- a/src/backend/src/services/repositories/DDBClient.ts +++ b/src/backend/src/services/repositories/DDBClient.ts @@ -161,13 +161,21 @@ export class DDBClient { return await this.#documentClient.send(command); } - async update> (table: string, key: T, expression: string, expressionValues: Record, expressionNames: Record) { + async update> ( + table: string, + key: T, + expression: string, + expressionValues?: Record, + expressionNames?: Record, + ) { + const hasValues = !!expressionValues && Object.keys(expressionValues).length > 0; + const hasNames = !!expressionNames && Object.keys(expressionNames).length > 0; const command = new UpdateCommand({ TableName: table, Key: key, UpdateExpression: expression, - ExpressionAttributeValues: expressionValues, - ExpressionAttributeNames: expressionNames, + ...(hasValues ? { ExpressionAttributeValues: expressionValues } : {}), + ...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}), ReturnValues: 'ALL_NEW', ReturnConsumedCapacity: 'TOTAL', }); diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.test.ts b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.test.ts index 464cedfee..9f024ca71 100644 --- a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.test.ts +++ b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.test.ts @@ -298,6 +298,94 @@ describe('DynamoKVStore', async () => { expect((stored as { a?: { b?: number[][] } }).a?.b).toEqual([[1], [2, 3]]); }); + it('supports nested list indexing for add, update, remove, and incr', async () => { + const actor = makeActor(21); + const key = 'nested-list-index'; + + await su.sudo(actor, () => kvStore.set({ + key, + value: { a: [1, { b: { c: [1] } }, 2] }, + })); + + const added = await su.sudo(actor, () => kvStore.add({ + key, + pathAndValueMap: { 'a[1].b.c': 2 }, + })); + expect((added as { a?: Array }).a).toEqual([1, { b: { c: [1, 2] } }, 2]); + + const updated = await su.sudo(actor, () => kvStore.update({ + key, + pathAndValueMap: { 'a[1].b.c': [9] }, + })); + expect((updated as { a?: Array }).a).toEqual([1, { b: { c: [9] } }, 2]); + + const removed = await su.sudo(actor, () => kvStore.remove({ + key, + paths: ['a[1].b.c'], + })); + expect((removed as { a?: Array }).a).toEqual([1, { b: {} }, 2]); + + await su.sudo(actor, () => kvStore.set({ + key, + value: { a: [1, { b: { c: 1 } }, 2] }, + })); + const incrRes = await su.sudo(actor, () => kvStore.incr({ + key, + pathAndAmountMap: { 'a[1].b.c': 3 }, + })); + expect((incrRes as { a?: Array }).a).toEqual([1, { b: { c: 4 } }, 2]); + }); + + it('removes nested values including indexed list paths', async () => { + const actor = makeActor(19); + const key = 'remove-list-index'; + + await su.sudo(actor, () => kvStore.set({ + key, + value: { a: { b: [1, 2, 3], c: { d: 4 }, e: 'keep' } }, + })); + + const updated = await su.sudo(actor, () => kvStore.remove({ + key, + paths: ['a.b[1]', 'a.c'], + })); + + expect((updated as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' }); + + const stored = await su.sudo(actor, () => kvStore.get({ key })); + expect((stored as { a?: { b?: number[]; e?: string } }).a).toEqual({ b: [1, 3], e: 'keep' }); + }); + + it('rejects overlapping parent/child paths in a single request', async () => { + const actor = makeActor(20); + const key = 'overlap-paths'; + + await su.sudo(actor, () => kvStore.set({ + key, + value: { a: { b: { c: 1 } } }, + })); + + await expect(su.sudo(actor, () => kvStore.incr({ + key, + pathAndAmountMap: { 'a.b': 1, 'a.b.c': 1 }, + }))).rejects.toThrow(/paths overlap/i); + + await expect(su.sudo(actor, () => kvStore.add({ + key, + pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 }, + }))).rejects.toThrow(/paths overlap/i); + + await expect(su.sudo(actor, () => kvStore.update({ + key, + pathAndValueMap: { 'a.b': 1, 'a.b.c': 2 }, + }))).rejects.toThrow(/paths overlap/i); + + await expect(su.sudo(actor, () => kvStore.remove({ + key, + paths: ['a.b', 'a.b.c'], + }))).resolves.not.toThrow(); + }); + it('incr initializes nested maps for missing keys', async () => { const actor = makeActor(14); const key = 'incr-missing'; diff --git a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts index 117e762b1..6fadcc114 100644 --- a/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts +++ b/src/backend/src/services/repositories/DynamoKVStore/DynamoKVStore.ts @@ -527,6 +527,67 @@ export class DynamoKVStore { return res.Attributes?.value; } + async remove ({ key, paths }: { key: string; paths: string[]; }): Promise { + if ( !paths || paths.length === 0 ) { + throw new Error('invalid use of #remove: no paths'); + } + if ( key === '' ) { + throw APIError.create('field_empty', null, { + key: 'key', + }); + } + + const actor = Context.get('actor'); + + const user = actor.type?.user ?? undefined; + if ( ! user ) throw new Error('User not found'); + + const namespace = this.#getNameSpace(actor); + + if ( this.#enableMigrationFromSQL ) { + // trigger get to move element if exists + await this.get({ key }); + } + + const cleanerRegex = /[:\-+/*]/g; + + const removeStatements = paths.map((valPath) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + return path.split('.').map((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + const indexSuffix = chunk.slice(cleanedChunk.length); + return `${`#${cleanedChunk}`.replaceAll(cleanerRegex, '')}${indexSuffix}`; + }).join('.'); + }); + + const valueAttributeNames = paths.reduce((acc, valPath) => { + const path = ['value', ...valPath.split('.')].filter(Boolean).join('.'); + path.split('.').forEach((chunk) => { + const cleanedChunk = chunk.split(/\[\d*\]/g)[0]; + acc[`#${cleanedChunk}`.replaceAll(cleanerRegex, '')] = cleanedChunk; + }); + return acc; + }, {} as Record); + + try { + const res = await this.#ddbClient.update(this.#tableName, + { key, namespace }, + `REMOVE ${removeStatements.join(', ')}`, + undefined, + { ...valueAttributeNames, '#value': 'value' }); + + this.#meteringService.incrementUsage(actor, 'kv:write', res?.ConsumedCapacity?.CapacityUnits ?? 1); + return res.Attributes?.value; + } catch ( e ) { + const message = (e as Error)?.message ?? ''; + if ( (e as Error)?.name === 'ValidationException' && /document path|invalid updateexpression/i.test(message) ) { + this.#meteringService.incrementUsage(actor, 'kv:write', 1); + return await this.get({ key }); + } + throw e; + } + } + async update ({ key, pathAndValueMap, ttl }: { key: string; pathAndValueMap: Record; ttl?: number; }): Promise { if ( !pathAndValueMap || Object.keys(pathAndValueMap).length === 0 ) { throw new Error('invalid use of #update: no pathAndValueMap'); diff --git a/src/puter-js/package-lock.json b/src/puter-js/package-lock.json index 90e747366..73f360b7a 100644 --- a/src/puter-js/package-lock.json +++ b/src/puter-js/package-lock.json @@ -1,12 +1,12 @@ { "name": "puter", - "version": "2.1.15", + "version": "2.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "puter", - "version": "2.1.15", + "version": "2.2.5", "license": "Apache-2.0", "dependencies": { "@heyputer/kv.js": "^0.1.92", diff --git a/src/puter-js/package.json b/src/puter-js/package.json index 06ad73334..efd953a61 100644 --- a/src/puter-js/package.json +++ b/src/puter-js/package.json @@ -1,6 +1,6 @@ { "name": "@heyputer/puter.js", - "version": "2.2.0", + "version": "2.2.5", "description": "Puter.js - A JavaScript library for interacting with Puter services.", "homepage": "https://developer.puter.com", "main": "src/index.js", diff --git a/src/puter-js/src/modules/KV.js b/src/puter-js/src/modules/KV.js index 110387070..48cb9f586 100644 --- a/src/puter-js/src/modules/KV.js +++ b/src/puter-js/src/modules/KV.js @@ -233,6 +233,38 @@ class KV { return utils.make_driver_method(['key'], 'puter-kvstore', undefined, 'add').call(this, options); }; + remove = async (...args) => { + if ( !args || args.length < 2 ) { + throw ({ message: 'At least one path is required', code: 'arguments_required' }); + } + + const key = args[0]; + const paths = args.slice(1); + + if ( Array.isArray(paths[0]) && paths.length === 1 ) { + throw ({ message: 'Paths must be provided as separate arguments', code: 'paths_invalid' }); + } + + if ( key === undefined || key === null ) { + throw ({ message: 'Key cannot be undefined', code: 'key_undefined' }); + } + + if ( key.length > this.MAX_KEY_SIZE ) { + throw ({ message: `Key size cannot be larger than ${this.MAX_KEY_SIZE}`, code: 'key_too_large' }); + } + + if ( paths.length === 0 ) { + throw ({ message: 'At least one path is required', code: 'arguments_required' }); + } + + if ( paths.some((path) => typeof path !== 'string') ) { + throw ({ message: 'All paths must be strings', code: 'paths_invalid' }); + } + + return utils.make_driver_method(['key', 'paths'], 'puter-kvstore', undefined, 'remove') + .call(this, { key, paths }); + }; + update = utils.make_driver_method(['key', 'pathAndValueMap', 'ttl'], 'puter-kvstore', undefined, 'update', { preprocess: (args) => { if ( args.key === undefined || args.key === null ) { diff --git a/src/puter-js/types/modules/kv.d.ts b/src/puter-js/types/modules/kv.d.ts index a21f5b396..1ac1738e5 100644 --- a/src/puter-js/types/modules/kv.d.ts +++ b/src/puter-js/types/modules/kv.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ export type KVValue = string | number | boolean | object | unknown; export type KVScalar = KVValue | KVValue[]; @@ -28,6 +29,7 @@ export class KV { incr (key: string, amount?: number | KVIncrementPath): Promise; decr (key: string, amount?: number | KVIncrementPath): Promise; add (key: string, value?: KVValue | KVAddPath): Promise; + remove (key: string, ...paths: string[]): Promise; update (key: string, pathAndValueMap: KVUpdatePath, ttlSeconds?: number): Promise; expire (key: string, ttlSeconds: number): Promise; expireAt (key: string, timestampSeconds: number): Promise; diff --git a/tests/puterJsApiTests/kv.test.ts b/tests/puterJsApiTests/kv.test.ts index c74873cea..e67b080c6 100644 --- a/tests/puterJsApiTests/kv.test.ts +++ b/tests/puterJsApiTests/kv.test.ts @@ -132,6 +132,15 @@ describe('Puter KV Module', () => { expect(finalGet).toEqual({ a: { b: [[1], [2, 3]] } }); }); + it('should remove nested values including indexed list paths', async () => { + const removeKey = `${TEST_KEY}-remove`; + await puter.kv.set(removeKey, { a: { b: [1, 2, 3], c: { d: 4 }, e: 'keep' } }); + const removeRes = await puter.kv.remove(removeKey, 'a.b[1]', 'a.c'); + expect(removeRes).toEqual({ a: { b: [1, 3], e: 'keep' } }); + const finalGet = await puter.kv.get(removeKey); + expect(finalGet).toEqual({ a: { b: [1, 3], e: 'keep' } }); + }); + it('should list keys', async () => { const listRes = await puter.kv.list(); expect(Array.isArray(listRes)).toBe(true);