Adding IdGenerator to generate increasing IDs

This can be extended to allow distributed insertion as well.

Change-Id: If24501523ec68b0f43679dd924c7771fd2758dc4
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMapping.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMapping.java
new file mode 100644
index 0000000..804f1d7
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMapping.java
@@ -0,0 +1,11 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+public interface DigitMapping {
+    char encode(int digit);
+    int decode(char encoded);
+    int radix();
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMappings.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMappings.java
new file mode 100644
index 0000000..925b40e
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitMappings.java
@@ -0,0 +1,88 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+import android.support.annotation.NonNull;
+
+import com.google.common.base.Function;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ContiguousSet;
+import com.google.common.collect.DiscreteDomain;
+import com.google.common.collect.ImmutableRangeSet;
+import com.google.common.collect.Range;
+import com.google.common.collect.RangeSet;
+
+public final class DigitMappings {
+    private DigitMappings() {
+    }
+
+    public static final DiscreteDomain<Character> DOMAIN = new DiscreteDomain<Character>() {
+        @Override
+        public Character next(@NonNull Character value) {
+            return value == Character.MAX_VALUE ? null : (char) (value + 1);
+        }
+
+        @Override
+        public Character previous(@NonNull Character value) {
+            return value == Character.MIN_VALUE ? null : (char) (value - 1);
+        }
+
+        @Override
+        public long distance(@NonNull Character start, @NonNull Character end) {
+            return end - start;
+        }
+
+        @Override
+        public Character maxValue() {
+            return Character.MAX_VALUE;
+        }
+
+        @Override
+        public Character minValue() {
+            return Character.MIN_VALUE;
+        }
+    };
+
+    public static final DigitMapping ALL = fromRangeSet(Range.<Character>all());
+
+    /**
+     * Constructs a {@link DigitMapping} from the given ranges, where the ranges are given in order
+     * of increasing significance. Although the ranges may jump around in significance, within each
+     * range, characters are still mapped to digits in ascending order. Out-of-order ranges may be
+     * used as well but cannot be optimized in this way.
+     *
+     * @see DigitRangeMapping
+     */
+    public static DigitMapping fromRanges(Iterable<ContiguousSet<Character>> ranges) {
+        return new DigitRangeMapping(ranges);
+    }
+
+    /**
+     * @see #fromRangeSet(RangeSet)
+     */
+    @SafeVarargs
+    public static DigitMapping fromRangeSet(Range<Character>... ranges) {
+        ImmutableRangeSet.Builder<Character> builder = ImmutableRangeSet.builder();
+        for (Range<Character> range : ranges) {
+            builder.add(range);
+        }
+        return fromRangeSet(builder.build());
+    }
+
+    /**
+     * Constructs a {@link DigitMapping} from a {@link RangeSet}, ordering the ranges in ascending
+     * order (regardless of input order). If out-of-order ranges are desired, use the
+     * {@link #fromRanges(Iterable)} factory method instead to provide explicit ordering.
+     */
+    public static DigitMapping fromRangeSet(RangeSet<Character> spec) {
+        return fromRanges(Collections2.transform(spec.asRanges(),
+                new Function<Range<Character>, ContiguousSet<Character>>() {
+                    @Override
+                    public ContiguousSet<Character> apply(Range<Character> input) {
+                        return ContiguousSet.create(input, DOMAIN);
+                    }
+                }));
+    }
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitRangeMapping.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitRangeMapping.java
new file mode 100644
index 0000000..3f3f140
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/DigitRangeMapping.java
@@ -0,0 +1,57 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+import com.google.common.collect.ContiguousSet;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * A {@link DigitMapping} optimized for mappings where ranges of consecutive characters map to
+ * consecutive values. The overall mapping need not be monotonic to take advantage of this
+ * optimization.
+ */
+public class DigitRangeMapping implements DigitMapping {
+    private final ImmutableList<ContiguousSet<Character>> mRanges;
+    private final int mRadix;
+
+    public DigitRangeMapping(Iterable<ContiguousSet<Character>> ranges) {
+        mRanges = ImmutableList.copyOf(ranges);
+        int size = 0;
+        for (ContiguousSet<Character> range : mRanges) {
+            size += range.size();
+        }
+        mRadix = size;
+    }
+
+    @Override
+    public char encode(int digit) {
+        for (ContiguousSet<Character> range : mRanges) {
+            if (digit < range.size()) {
+                return range.asList().get(digit);
+            } else {
+                digit -= range.size();
+            }
+        }
+        throw new IllegalArgumentException("No encoding for digit " + digit + " (radix " + radix() +
+                ")");
+    }
+
+    @Override
+    public int decode(char encoded) {
+        int digit = 0;
+        for (ContiguousSet<Character> range : mRanges) {
+            if (range.contains(encoded)) {
+                return digit + encoded - range.first();
+            }
+            digit += range.size();
+        }
+        throw new IllegalArgumentException();
+    }
+
+    @Override
+    public int radix() {
+        return mRadix;
+    }
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabet.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabet.java
new file mode 100644
index 0000000..a42ef4e
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabet.java
@@ -0,0 +1,16 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+public interface IdAlphabet {
+    char encodeDigit(int digit);
+    int decodeDigit(char encoded);
+
+    char encodeLeadingDigit(int digit);
+    int decodeLeadingDigit(char encoded);
+
+    int radix();
+    int leadingRadix();
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabets.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabets.java
new file mode 100644
index 0000000..673ce4b
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdAlphabets.java
@@ -0,0 +1,63 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+import com.google.common.collect.Range;
+
+public final class IdAlphabets {
+    private IdAlphabets() {
+    }
+
+    public static final IdAlphabet
+            COLLECTION_ID = fromDigitMappings(
+            DigitMappings.fromRangeSet(
+                    Range.closed('0', '9'),
+                    Range.closed('A', 'Z'),
+                    Range.singleton('_'),
+                    Range.closed('a', 'z')),
+            DigitMappings.fromRangeSet(
+                    Range.closed('A', 'Z'),
+                    Range.closed('a', 'z'))),
+            ROW_NAME = fromDigitMapping(DigitMappings.ALL);
+
+    public static IdAlphabet fromDigitMapping(final DigitMapping digitMapping) {
+        return fromDigitMappings(digitMapping, digitMapping);
+    }
+
+    public static IdAlphabet fromDigitMappings(final DigitMapping digitMapping,
+                                               final DigitMapping leadingDigitMapping) {
+        return new IdAlphabet() {
+            @Override
+            public char encodeDigit(int digit) {
+                return digitMapping.encode(digit);
+            }
+
+            @Override
+            public int decodeDigit(char encoded) {
+                return digitMapping.decode(encoded);
+            }
+
+            @Override
+            public char encodeLeadingDigit(int digit) {
+                return leadingDigitMapping.encode(digit);
+            }
+
+            @Override
+            public int decodeLeadingDigit(char encoded) {
+                return leadingDigitMapping.decode(encoded);
+            }
+
+            @Override
+            public int radix() {
+                return digitMapping.radix();
+            }
+
+            @Override
+            public int leadingRadix() {
+                return leadingDigitMapping.radix();
+            }
+        };
+    }
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdGenerator.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdGenerator.java
new file mode 100644
index 0000000..10e77c7
--- /dev/null
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/IdGenerator.java
@@ -0,0 +1,257 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+import java.util.UUID;
+
+public class IdGenerator {
+    // TODO(rosswang): Escape collection IDs to allow any character. A lot of this logic can be
+    // simplified if we can use any character.
+    // TODO(rosswang): Put this into Syncbase utils.
+
+    private final IdAlphabet mAlphabet;
+
+    /**
+     * the length we *would* need if we could use an underscore for the first character
+     */
+    private final int mLongUniformRadixLength;
+    private final int mLongEncodedLength;
+
+    /**
+     * This is related to unsigned division.
+     * @see #transformToSignedDivision(long)
+     */
+    private final int mMaxSafeMostSignificantDigit;
+    /**
+     * This is related to unsigned division.
+     * @see #transformToSignedDivision(long)
+     */
+    private final long mMostSignificantUnit, mMaxSafeMostSignificantValue;
+
+    private static final double LOG_LONG = Long.SIZE * Math.log(2);
+
+    /**
+     * @param alphabet the alphabet of legal characters for IDs generated by this generator
+     * @param perturb whether or not to perturb generated IDs by a random perturbation to avoid
+     *                collisions in distributed key generation. Pass {@code false} if this generator
+     *                is used to generate a sort key that is a secondary key where ordering
+     *                conflicts can be deterministically resolved by the primary key. This setting
+     *                only applies to the {@code generate...} methods.
+     */
+    public IdGenerator(IdAlphabet alphabet, boolean perturb) {
+        mAlphabet = alphabet;
+        mPerturb = perturb;
+
+        // ceil(log_RADIX(Long.MAX + 1))
+        mLongUniformRadixLength = (int)Math.ceil(LOG_LONG / Math.log(alphabet.radix()));
+
+        // int pow, msu = radix ^ url
+        long mostSignificantUnit = alphabet.radix();
+        for (int i = 2; i < mLongUniformRadixLength; i++) {
+            mostSignificantUnit *= alphabet.radix();
+        }
+        mMostSignificantUnit = mostSignificantUnit;
+
+        mMaxSafeMostSignificantDigit = (int)(Long.MAX_VALUE / mMostSignificantUnit);
+        mMaxSafeMostSignificantValue = mMaxSafeMostSignificantDigit * mMostSignificantUnit;
+
+        // The first character may have additional restrictions, so we have less chars to work with
+        // for the first digit. If we still have enough digits, we'll proceed and just use a special
+        // mapping for the leading digit. Otherwise, we'll pad by a leading 0, thus making the
+        // remaining encoding the common case.
+
+        // So, what most-significant digit do we need to encode the max unsigned value?
+        PartialDivisionResult div = transformToSignedDivision(-1);
+        int maxMostSignfiicantDigit = div.partialMostSignificantDigit +
+                (int)(div.partialRemainder / mMostSignificantUnit);
+        mLongEncodedLength = maxMostSignfiicantDigit < alphabet.leadingRadix() ?
+                mLongUniformRadixLength : mLongUniformRadixLength + 1;
+    }
+
+    private static class PartialDivisionResult {
+        public int partialMostSignificantDigit;
+        public long partialRemainder;
+
+        public PartialDivisionResult(int partialMostSignificantDigit, long partialRemainder) {
+            this.partialMostSignificantDigit = partialMostSignificantDigit;
+            this.partialRemainder = partialRemainder;
+        }
+    }
+
+    /**
+     * Performs partial calculation of the most significant digit for the alphabet-radix
+     * representation of {@code unsigned}. To achieve unsigned division, we first assume the
+     * most significant digit is at least as large as the max safe most significant digit for signed
+     * division, add one more if neccessary to push the "remainder" positive, and then do normal
+     * division for the rest.
+     */
+    private PartialDivisionResult transformToSignedDivision(long unsigned) {
+        if (unsigned < 0) {
+            long naiveRemainder = unsigned - mMaxSafeMostSignificantValue;
+            return naiveRemainder < 0 ?
+                    // need one more
+                    new PartialDivisionResult(mMaxSafeMostSignificantDigit + 1,
+                            naiveRemainder - mMostSignificantUnit) :
+                    new PartialDivisionResult(mMaxSafeMostSignificantDigit, naiveRemainder);
+        } else {
+            // Already positive; handle with normal arithmetic.
+            return new PartialDivisionResult(0, unsigned);
+        }
+    }
+
+    /**
+     * Converts a signed {@code long} to a {@code String} consistent with the pattern
+     * {@code [0-9A-Za-z][0-9A-Za-z_]*}, required by Syncbase for
+     * {@link io.v.v23.services.syncbase.Id} names, with lexicographic ordering consistent with the
+     * numeric ordering.
+     * <p>
+     * Example transformations using {@link IdAlphabets#COLLECTION_ID}:
+     * <pre>
+     * -9223372036854775808 -> 00000000000
+     * -9223372036854775807 -> 00000000001
+     * -9223372036854775806 -> 00000000002
+     *                   -1 -> 9MxfBQuKjH7
+     *                    0 -> 9MxfBQuKjH8
+     *                    1 -> 9MxfBQuKjH9
+     *  9223372036854775805 -> IivLMqoeTYD
+     *  9223372036854775806 -> IivLMqoeTYE
+     *  9223372036854775807 -> IivLMqoeTYF
+     * </pre>
+     */
+    public String longToPaddedIdentifier(long x) {
+        StringBuilder s = new StringBuilder(mLongEncodedLength);
+        long unsignedWithSameOrdering = x - Long.MIN_VALUE;
+        PartialDivisionResult signedSubproblem =
+                transformToSignedDivision(unsignedWithSameOrdering);
+        long leftToEncode = signedSubproblem.partialRemainder;
+
+        for (int placeValue = 0; placeValue < mLongUniformRadixLength - 1; placeValue++) {
+            if (leftToEncode > 0) {
+                s.append(mAlphabet.encodeDigit((int) (leftToEncode % mAlphabet.radix())));
+                leftToEncode /= mAlphabet.radix();
+            } else {
+                s.append(mAlphabet.encodeDigit(0));
+            }
+        }
+
+        // The most-significant digit needs to be handled specially, accounting for the signed
+        // division adjustment and using a possibly different character mapping.
+        int mostSignificantDigit = signedSubproblem.partialMostSignificantDigit + (int)leftToEncode;
+        if (mLongEncodedLength > mLongUniformRadixLength) {
+            // just 0-pad
+            s.append(mAlphabet.encodeDigit(mostSignificantDigit))
+                    .append(mAlphabet.encodeLeadingDigit(0));
+        } else {
+            // use the special leading-digit mapping
+            s.append(mAlphabet.encodeLeadingDigit(mostSignificantDigit));
+        }
+
+        return s.reverse().toString();
+    }
+
+    /**
+     * Perturbs IDs to mitigate collision during distributed, eventually consistent insertion.
+     * {@link UUID#randomUUID()} is used for perturbation. The perturbed ID is strictly greater than
+     * the base ID but less than the next increment from the base ID except in the degenerate case
+     * where the base ID must be extended to increment.
+     */
+    public String perturb(String baseId) {
+        UUID uuid = UUID.randomUUID();
+        return baseId +
+                longToPaddedIdentifier(uuid.getMostSignificantBits()) +
+                longToPaddedIdentifier(uuid.getLeastSignificantBits());
+    }
+
+    /**
+     * Increments an ID with a proposed base length. If the increment cannot be done under these
+     * constraints, the ID is extended. The incremented ID is guaranteed to be greater than the
+     * given {@code fullId}.
+     * <p>
+     * Example transformations:
+     * <pre>
+     * 0000 -> 01
+     * AZ00 -> A_
+     * Az00 -> B0
+     * Zz00 -> a0
+     * zz00 -> zz1
+     * zzz0 -> zzz1
+     * zzzz -> zzzz0
+     * </pre>
+     */
+    public String increment(String fullId, int proposedCutoff) {
+        StringBuilder mutable = new StringBuilder(fullId);
+
+        if (proposedCutoff > fullId.length()) {
+            proposedCutoff = fullId.length();
+        }
+
+        // First try normal incrementing
+        for (int i = proposedCutoff - 1; i > 0; i--) {
+            int newValue = mAlphabet.decodeDigit(mutable.charAt(i)) + 1;
+            if (newValue < mAlphabet.radix()) {
+                mutable.setCharAt(i, mAlphabet.encodeDigit(newValue));
+                mutable.setLength(proposedCutoff);
+                return mutable.toString();
+            } else {
+                mutable.setCharAt(i, mAlphabet.encodeDigit(0));
+            }
+        }
+
+        // Process the leading digit specially
+        if (proposedCutoff > 0){
+            int newValue = mAlphabet.decodeLeadingDigit(mutable.charAt(0)) + 1;
+            if (newValue < mAlphabet.leadingRadix()) {
+                mutable.setCharAt(0, mAlphabet.encodeLeadingDigit(newValue));
+                mutable.setLength(proposedCutoff);
+                return mutable.toString();
+            }
+        }
+
+        // If we've gotten to this point, we can't increment the prefix; start looking at the
+        // suffix.
+
+        // Reset mutable
+        mutable.setLength(0);
+        mutable.append(fullId);
+
+        for (int i = proposedCutoff; i < fullId.length(); i++) {
+            int newValue = mAlphabet.decodeDigit(mutable.charAt(i)) + 1;
+            if (newValue < mAlphabet.radix()) {
+                mutable.setCharAt(i, mAlphabet.encodeDigit(newValue));
+                mutable.setLength(i + 1);
+                return mutable.toString();
+            }
+        }
+        // By this point, we just have to extend the string.
+        mutable.append(mAlphabet.encodeDigit(0));
+        return mutable.toString();
+    }
+
+    public String increment(String previous) {
+        return increment(previous, mLongEncodedLength);
+    }
+
+    private final Object mLargestKnownIdMutex = new Object();
+    private String mLargestKnownId;
+    private final boolean mPerturb;
+
+    public String generateTailId() {
+        String candidate = longToPaddedIdentifier(System.currentTimeMillis());
+        synchronized (mLargestKnownIdMutex) {
+            if (mLargestKnownId != null && candidate.compareTo(mLargestKnownId) <= 0) {
+                candidate = increment(mLargestKnownId);
+            }
+            return mLargestKnownId = mPerturb? perturb(candidate) : candidate;
+        }
+    }
+
+    public void registerId(String id) {
+        synchronized (mLargestKnownIdMutex) {
+            if (mLargestKnownId == null || id.compareTo(mLargestKnownId) > 0) {
+                mLargestKnownId = id;
+            }
+        }
+    }
+}
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/MainListTracker.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/MainListTracker.java
index be1f568..11545d3 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/MainListTracker.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/MainListTracker.java
@@ -85,7 +85,7 @@
     private void processWatchChange(WatchChange change) {
         String rowName = change.getRowName();
 
-        if (rowName.equals(SyncbaseTodoList.LIST_ROW_NAME)) {
+        if (rowName.equals(SyncbaseTodoList.LIST_METADATA_ROW_NAME)) {
             mListSpec = SyncbasePersistence.castWatchValue(change.getValue(), ListSpec.class);
         } else if (change.getChangeType() == ChangeType.DELETE_CHANGE) {
             if (mIsTaskCompleted.remove(rowName)) {
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
index 8d6b190..9e77ae3 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseMain.java
@@ -45,6 +45,7 @@
             TAG = SyncbaseMain.class.getSimpleName(),
             LISTS_PREFIX = "lists_";
 
+    private final IdGenerator mIdGenerator = new IdGenerator(IdAlphabets.COLLECTION_ID, true);
     private final Map<String, MainListTracker> mTaskTrackers = new HashMap<>();
 
     /**
@@ -64,6 +65,8 @@
                 if (change.getChangeType() == ChangeType.DELETE_CHANGE) {
                     trap(mTaskTrackers.remove(listId).deleteList(mVContext));
                 } else {
+                    mIdGenerator.registerId(change.getRowName().substring(LISTS_PREFIX.length()));
+
                     MainListTracker listTracker = new MainListTracker(
                             mVContext, getDatabase(), listId, listener);
                     if (mTaskTrackers.put(listId, listTracker) != null) {
@@ -82,7 +85,7 @@
 
     @Override
     public void addTodoList(final ListSpec listSpec) {
-        final String listName = LISTS_PREFIX + randomName();
+        final String listName = LISTS_PREFIX + mIdGenerator.generateTailId();
         final Collection listCollection = getDatabase().getCollection(mVContext, listName);
         Futures.addCallback(listCollection.create(mVContext, null),
                 new TrappingCallback<Void>(mActivity) {
@@ -90,8 +93,8 @@
                     public void onSuccess(@Nullable Void result) {
                         // These can happen in either order
                         trap(getUserCollection().put(mVContext, listName, null, VdlAny.class));
-                        trap(listCollection.put(mVContext, SyncbaseTodoList.LIST_ROW_NAME, listSpec,
-                                ListSpec.class));
+                        trap(listCollection.put(mVContext, SyncbaseTodoList.LIST_METADATA_ROW_NAME,
+                                listSpec, ListSpec.class));
                     }
                 });
     }
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
index b2b0db9..e475d3d 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbasePersistence.java
@@ -24,7 +24,6 @@
 import com.google.common.util.concurrent.SettableFuture;
 
 import java.io.File;
-import java.util.UUID;
 import java.util.concurrent.Executors;
 
 import javax.annotation.Nullable;
@@ -186,10 +185,6 @@
         return sUserCollection != null;
     }
 
-    protected static String randomName() {
-        return UUID.randomUUID().toString().replace('-', '_');
-    }
-
     /**
      * A {@link FutureCallback} that reports persistence errors by toasting a short message to the
      * user and logging the exception trace and the call stack from where the future was invoked.
diff --git a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
index 49a4a14..beb8fc5 100644
--- a/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
+++ b/app/src/syncbase/java/io/v/todos/persistence/syncbase/SyncbaseTodoList.java
@@ -32,7 +32,7 @@
 
 public class SyncbaseTodoList extends SyncbasePersistence implements TodoListPersistence {
     public static final String
-            LIST_ROW_NAME = "list",
+            LIST_METADATA_ROW_NAME = "list",
             TASKS_PREFIX = "tasks_";
 
     private static final String
@@ -40,6 +40,7 @@
 
     private final Collection mList;
     private final TodoListListener mListener;
+    private final IdGenerator mIdGenerator = new IdGenerator(IdAlphabets.ROW_NAME, true);
     private final Set<String> mTaskIds = new HashSet<>();
 
     /**
@@ -88,7 +89,7 @@
     private void processWatchChange(WatchChange change) {
         String rowName = change.getRowName();
 
-        if (rowName.equals(SyncbaseTodoList.LIST_ROW_NAME)) {
+        if (rowName.equals(SyncbaseTodoList.LIST_METADATA_ROW_NAME)) {
             ListSpec listSpec = SyncbasePersistence.castWatchValue(change.getValue(),
                     ListSpec.class);
             mListener.onUpdate(listSpec);
@@ -96,6 +97,8 @@
             mTaskIds.remove(rowName);
             mListener.onItemDelete(rowName);
         } else {
+            mIdGenerator.registerId(change.getRowName().substring(TASKS_PREFIX.length()));
+
             TaskSpec taskSpec = SyncbasePersistence.castWatchValue(change.getValue(),
                     TaskSpec.class);
             Task task = new Task(rowName, taskSpec);
@@ -110,7 +113,7 @@
 
     @Override
     public void updateTodoList(ListSpec listSpec) {
-        trap(mList.put(mVContext, LIST_ROW_NAME, listSpec, ListSpec.class));
+        trap(mList.put(mVContext, LIST_METADATA_ROW_NAME, listSpec, ListSpec.class));
     }
 
     @Override
@@ -121,13 +124,13 @@
 
     public static ListenableFuture<Void> updateListTimestamp(final VContext vContext,
                                                              final Collection list) {
-        ListenableFuture<Object> get = list.get(vContext, LIST_ROW_NAME, ListSpec.class);
-        return Futures.transform(get, new AsyncFunction<Object, Void>() {
+        ListenableFuture<Object> get = list.get(vContext, LIST_METADATA_ROW_NAME, ListSpec.class);
+        return Futures.transformAsync(get, new AsyncFunction<Object, Void>() {
             @Override
             public ListenableFuture<Void> apply(Object oldValue) throws Exception {
                 ListSpec listSpec = (ListSpec) oldValue;
                 listSpec.setUpdatedAt(System.currentTimeMillis());
-                return list.put(vContext, LIST_ROW_NAME, listSpec, ListSpec.class);
+                return list.put(vContext, LIST_METADATA_ROW_NAME, listSpec, ListSpec.class);
             }
         });
     }
@@ -138,7 +141,8 @@
 
     @Override
     public void addTask(TaskSpec task) {
-        trap(mList.put(mVContext, TASKS_PREFIX + randomName(), task, TaskSpec.class));
+        trap(mList.put(mVContext, TASKS_PREFIX + mIdGenerator.generateTailId(), task,
+                TaskSpec.class));
         updateListTimestamp();
     }
 
diff --git a/app/src/testSyncbase/java/io/v/todos/persistence/syncbase/IdGeneratorTest.java b/app/src/testSyncbase/java/io/v/todos/persistence/syncbase/IdGeneratorTest.java
new file mode 100644
index 0000000..996db6d
--- /dev/null
+++ b/app/src/testSyncbase/java/io/v/todos/persistence/syncbase/IdGeneratorTest.java
@@ -0,0 +1,100 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.todos.persistence.syncbase;
+
+import org.junit.Test;
+
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class IdGeneratorTest {
+    private static final Pattern COLLECTION_ID_PATTERN = Pattern.compile("[0-9A-Za-z][0-9A-Za-z_]*");
+    private static final Random RANDOM = new Random();
+
+    @Test
+    public void testLongToPaddedIdentifier() {
+        IdGenerator
+                testGeneratorA = new IdGenerator(IdAlphabets.COLLECTION_ID, true),
+                testGeneratorB = new IdGenerator(IdAlphabets.ROW_NAME, true);
+
+        long[] sortedInputs = new long[]{
+                Long.MIN_VALUE,
+                Long.MIN_VALUE + 1,
+                Long.MIN_VALUE + 2,
+                -1,
+                0,
+                1,
+                Long.MAX_VALUE - 2,
+                Long.MAX_VALUE - 1,
+                Long.MAX_VALUE
+        };
+        String lastIdA = null, lastIdB = null;
+        for (long input : sortedInputs) {
+            String testId = testGeneratorA.longToPaddedIdentifier(input);
+            if (lastIdA != null) {
+                assertTrue("IdGenerator.longToPaddedIdentifier generates IDs consistent with " +
+                        "input ordering", testId.compareTo(lastIdA) > 0);
+            }
+            lastIdA = testId;
+
+            testId = testGeneratorB.longToPaddedIdentifier(input);
+            if (lastIdB != null) {
+                assertTrue("IdGenerator.longToPaddedIdentifier generates IDs consistent with " +
+                        "input ordering", testId.compareTo(lastIdB) > 0);
+            }
+            lastIdB = testId;
+        }
+
+        for (int i = 0; i < 50; i++) {
+            // Smoke fuzz test
+            testGeneratorA.longToPaddedIdentifier(RANDOM.nextLong());
+            testGeneratorB.longToPaddedIdentifier(RANDOM.nextLong());
+        }
+    }
+
+    @Test
+    public void testDecodeChar() {
+        for (int i = 0; i < IdAlphabets.COLLECTION_ID.radix(); i++) {
+            char testChar = IdAlphabets.COLLECTION_ID.encodeDigit(i);
+            assertEquals("IdGenerator.decodeChar('" + testChar + "')",
+                    i, IdAlphabets.COLLECTION_ID.decodeDigit(testChar));
+        }
+    }
+
+    private void verifyIncrement(String input, String incremented) {
+        String diagnostic = input + " + 1 = " + incremented;
+        assertTrue("IdGenerator.increment generates larger IDs than the input (" + diagnostic + ")",
+                incremented.compareTo(input) > 0);
+        assertTrue("IdGenerator.increment generates legal IDs (" + diagnostic + ")",
+                COLLECTION_ID_PATTERN.matcher(incremented).matches());
+    }
+
+    @Test
+    public void testIncrement() {
+        IdGenerator testGenerator = new IdGenerator(IdAlphabets.COLLECTION_ID, true);
+
+        String[] inputs = new String[]{
+                "0000",
+                "AZ00",
+                "Az00",
+                "Zz00",
+                "zz00",
+                "zzz0",
+                "zzzz"
+        };
+
+        for (String input : inputs) {
+            verifyIncrement(input, testGenerator.increment(input, 2));
+        }
+
+        for (int i = 0; i < 50; i++) {
+            String input = testGenerator.longToPaddedIdentifier(RANDOM.nextLong());
+            verifyIncrement(input, testGenerator.increment(input, RANDOM.nextInt(input.length())));
+        }
+    }
+}
\ No newline at end of file